mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(nesdoctor): a host readiness checker that measures instead of asking (#310)
The first executable form of our host requirements. Until now a machine
was qualified by a human reading a table of hard requirements — and a
requirement that nothing can check is one that is silently optional.
It also replaces a form. Everything we wanted from a prospective host is
measurable, and most of it **cannot be answered honestly by a human
anyway**: almost nobody knows their real upstream, and essentially
nobody has ever seen their own bufferbloat figure. What's left for the
questions is only what a machine cannot know — intent, and what someone
already pays.
## What it does
```
nesdoctor
```
- **Checks every hard requirement**: `/dev/kvm`, an AMD or Intel GPU
with a DRM render node, `VK_KHR_video_encode_queue` plus a codec,
`virglrenderer`, the two stores, the `io` cgroup controller,
`virtiofsd`. Pass / fail / **unknown**, and unknown is never collapsed
into fail — a machine we could not ask is not a machine that failed, and
losing a capable host to a missing `lspci` is the failure mode that
matters.
- **Measures upstream and, the point of the whole thing, added latency
under load.** Grade bands come from the frame budget rather than
convention: the network allowance is ~40 ms because render, encode,
decode, display and jitter buffer have already spent ~58 ms.
- **Reads Steam, only with an explicit yes**, for library size and shape
plus an hour-of-day histogram of launches — one sample per title, which
is a real distribution obtained without asking anybody anything.
- **Asks at most five questions**, branched on what was found, all
skippable.
## No server
Nothing is uploaded and no telemetry endpoint exists. The network test
talks to Cloudflare's public speed-test sink and to `1.1.1.1`, neither
of which is ours. The output is a line on the terminal that the person
may choose to paste.
The shareable line carries **no hostname, IP, username, game title or
path** — a size band rather than a size, hours rather than dates. The
long version, which does include titles and paths, stays in a local JSON
file the person is told the path of.
That is a property of the design and not a promise about our intentions:
there is nothing to switch on later.
```
nesdoctor 0.1.0 | linux/x86_64 | gpu=AMD Barcelo | cpu=12t ram=13G |
kvm=y venc=y zfs=n boxfs=n io=y | up=28Mbps rtt=179ms bloat=+19ms grade=B |
disk=91G | edge=KE/JNB | steam=1 titles/<100G | plays=20-03h n=74 |
role=- share=- pays=- | HOST-READY-LOCAL
```
## Five bugs found by running it, every one of which would have produced
wrong data
- **`vulkaninfo --summary` lists ZERO `VK_KHR_video` entries** where
full `vulkaninfo` lists five on the same machine. Preferring the summary
reported "not advertised" on a card that advertises it — a false
negative on the check most likely to disqualify a host.
- **btrfs subvolumes counted as separate disks**: `/`, `/home` and
`/srv` each reporting 91 GiB of one 91 GiB device. Now deduped by
backing device, which the two-stores check needs anyway since it wants
*separate devices*.
- **Proton and the Steam Linux Runtimes are installed like games and are
not games.** Five of eight entries on the test machine, so the title
count was 5× too high and the library-shape question was corrupted.
- **`--quiet` printed the whole questionnaire** before its summary line,
breaking the one thing `--quiet` promises. Prompts are now skipped when
output is quiet or stdin is not a terminal — and a pipe is explicitly
*not* treated as consent to read a Steam library, unlike `--yes`.
- Boot history was reporting `13.2 h/day` off **two days** of history.
Under a three-day span it now reports the span and no rate.
## One finding, now encoded as a verdict
The development connection measures **179 ms idle RTT, served from
Johannesburg**. That machine passes every other check and cannot host
for a European player, because it is distance and no upgrade shortens
it.
`HOST-READY-LOCAL` exists for exactly that case, and the wording is
deliberate:
> Every requirement passes and your uplink queues cleanly. But the idle
round trip to the nearest major network is already most of the latency
budget, and that is distance rather than a fault: no upgrade shortens
it. So this machine is a good host for people on your side of the world
and cannot be one for anybody else. **If you are somewhere without a
cloud gaming edge, that is not a consolation prize — it is the only way
anyone there gets a playable stream.**
## CI
- **`ci.yml` gains a `nesdoctor` job** — fmt, `clippy -D warnings`,
test, one real run. Scoped to this member deliberately: the rest of the
Rust half has never been under CI, so `--workspace` would turn every PR
red for unrelated reasons. Widen it one member at a time.
- **`release-nesdoctor.yml`** builds four targets on tag `nesdoctor-v*`
— x86_64 linux-musl, x86_64 windows-msvc, aarch64 and x86_64 macOS —
with `SHA256SUMS`. musl rather than glibc so one Linux binary runs on
every distro.
The step that justifies the workflow **runs the binary it just built,
network included**. `ring` under rustls resolves root certificates
through the host trust store, so a static musl build can compile cleanly
and then fail TLS on the machine it ships to — breaking the network
test, silently, and only for other people. The step fails the build if
the summary line comes back `net=unmeasured`.
## Dependencies
Four: `anyhow`, `clap`, `serde`, `ureq`. The VDF parser, every platform
probe and the text wrapping are in-tree. A binary handed to strangers
has a dependency tree that is part of its interface, so anything that
could be done with `std` is.
4 MB release binary.
## What it deliberately does not claim
- **A pass is not a promise.** Every check is a *necessary* condition,
and nothing here runs under load — a machine that passes can still fail
on block I/O.
- **The encode extension being advertised is not proof the path works.**
We have had a correct extension list over a broken path before, so that
row says so.
- **Whether `libvirglrenderer` carries the native-context patches cannot
be determined from outside**, so that row reports presence only and
stays `unknown` rather than `pass`.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
The PR adds the nesdoctor host-readiness executable, local Steam
analysis, network measurement, installers, CI validation, and
multi-platform release packaging. Two attempted correctness fixes remain
incomplete:
- physical disk deduplication does not resolve common device-mapper
source names before comparing backing devices
- unknown historical Steam appids can still be counted as game launches
without passing runtime filtering
<h3>Confidence Score: 3/5</h3>
The PR is not yet safe to merge because shared LVM-backed stores can be
reported as physically independent and unknown Steam tools can still be
reported as game launches.
The new disk resolver fails open for common device-mapper names,
preserving a false host-readiness verdict, while Steam history still
counts absent appids without determining whether they are games or
runtime tools.
**Files Needing Attention:** apps/nesdoctor/src/sys.rs,
apps/nesdoctor/src/hostreq.rs, apps/nesdoctor/src/steam.rs
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nesdoctor/src/sys.rs | Adds system and disk discovery, but
unresolved device-mapper names undermine physical-backing comparisons. |
| apps/nesdoctor/src/hostreq.rs | Implements host requirement verdicts
and uses physical-device sets that can falsely classify shared LVM
backing as independent. |
| apps/nesdoctor/src/steam.rs | Adds manifest and launch-history
analysis, but unknown appids bypass runtime classification and
contaminate launch metrics. |
| apps/nesdoctor/src/net.rs | Adds bounded upload-based upstream and
bufferbloat measurement; the previously reported unbounded request path
is addressed. |
| .github/workflows/release-nesdoctor.yml | Builds, smoke-tests,
packages, checksums, and publishes the four release targets. |
| .github/workflows/ci.yml | Adds focused formatting, linting, testing,
and offline execution checks for nesdoctor. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Run[nesdoctor] --> Host[Host requirement probes]
Run --> Net[Upload and latency measurement]
Run --> Consent{Steam consent}
Consent -->|yes| Steam[Installed manifests and LastPlayed records]
Host --> Physical[Resolve filesystem sources to physical devices]
Physical --> Verdict[Host readiness verdict]
Net --> Report[Detailed JSON and shareable summary]
Steam --> Report
Verdict --> Report
```
<details><summary>Prompt To Fix All With AI</summary>
`````markdown
### Issue 1
apps/nesdoctor/src/sys.rs:369-374
**Mapper devices remain unresolved**
When root and box-store filesystems are separate LVM or dm-crypt mappings on the same physical disk, `df` supplies `/dev/mapper/...` names that do not exist under `/sys/class/block`. This branch returns those unrelated logical names unchanged, so the overlap check passes stores that still share one physical I/O queue.
### Issue 2
apps/nesdoctor/src/steam.rs:247-250
**Unknown appids bypass runtime filtering**
If `localconfig.vdf` retains `LastPlayed` data for an uninstalled Proton build, Steam runtime, or other non-game tool, its appid is absent from the installed-manifest map and this branch treats it as an uninstalled game. The tool activity then changes the launch histogram, peak window, and shareable `n` value.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
`````
</details>
<sub>Reviews (5): Last reviewed commit: ["fix(nesdoctor): three valid P1
findings
..."](7afc8929a6)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=59231233)</sub>
> Greptile also left **2 inline comments** on this PR.
<!-- /greptile_comment -->
This commit is contained in:
25
apps/nesdoctor/Cargo.toml
Normal file
25
apps/nesdoctor/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "nesdoctor"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "nesdoctor"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Dependencies are deliberately few. This binary is handed to strangers and
|
||||
# asked to be trusted with a look at their machine, so the dependency tree is
|
||||
# part of the interface: a reviewer has to be able to read all of it. Anything
|
||||
# that can be done with `std` is done with `std` -- the VDF parser and every
|
||||
# platform probe shell out or read files rather than pulling a crate.
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
# The one non-trivial dependency, and only for the throughput half of the
|
||||
# network test. rustls rather than native-tls so there is no OpenSSL to find.
|
||||
ureq = "3"
|
||||
141
apps/nesdoctor/README.md
Normal file
141
apps/nesdoctor/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# nesdoctor
|
||||
|
||||
**Is this machine any good — as a Nestri host, or as a client?**
|
||||
|
||||
```sh
|
||||
# macOS and Linux
|
||||
curl -fsSL https://doctor.nestri.io/install.sh | sh
|
||||
|
||||
# Windows
|
||||
powershell -c "irm https://doctor.nestri.io/install.ps1 | iex"
|
||||
```
|
||||
|
||||
Both download one binary, verify its checksum, run it, and delete it. They
|
||||
install nothing, need no administrator rights, and touch no system directory.
|
||||
**The scripts those URLs serve are the files in [`install/`](install/)** — the
|
||||
worker fetches them from this repository, so you can read exactly what you are
|
||||
about to run before you run it.
|
||||
|
||||
Or build it yourself:
|
||||
|
||||
```
|
||||
cargo run --release -p nesdoctor
|
||||
```
|
||||
|
||||
Checks every hard requirement for running a Nestri box, measures what your
|
||||
connection actually does when it is busy, and asks at most five questions.
|
||||
|
||||
## Nothing is uploaded
|
||||
|
||||
There is no server. No telemetry endpoint exists, and there is no build of this
|
||||
program that reports home — the network test talks to Cloudflare's public
|
||||
speed-test sink and to `1.1.1.1`, neither of which is ours.
|
||||
|
||||
The output is a line on your terminal. If you want us to have it, you paste it
|
||||
somewhere. If you do not, we never had it. That is a property of the design
|
||||
rather than a promise about our intentions: there is nothing to switch on later.
|
||||
|
||||
The shareable line carries **no hostname, no IP, no username, no game titles and
|
||||
no file paths** — a size band rather than a size, and an hour histogram rather
|
||||
than timestamps. It is put on your clipboard at the end so pasting it is one
|
||||
keystroke.
|
||||
|
||||
The long version lands in `nesdoctor.json`: every check with its reason, the
|
||||
full latency series, and your installed titles with sizes and launch times if
|
||||
you said yes to Steam. **That file is considerably more useful to us than the
|
||||
line** — it is what lets us size a game library and see which requirement
|
||||
actually stops people — so do have a read through it and send it along if
|
||||
nothing in there bothers you. Plain JSON, entirely optional, and the one-line
|
||||
version is already plenty.
|
||||
|
||||
Reading your Steam library needs an explicit yes, and the question is asked last,
|
||||
after you have seen what this program does.
|
||||
|
||||
## The number worth running it for
|
||||
|
||||
Everybody knows their download speed. Almost nobody has seen **how much latency
|
||||
their connection adds when it is busy**, and for anything interactive that is the
|
||||
figure that decides it:
|
||||
|
||||
```
|
||||
upstream 28 Mbps
|
||||
latency, idle 179 ms
|
||||
latency, loaded 198 ms
|
||||
added under load +19 ms grade B
|
||||
```
|
||||
|
||||
A 500 Mbps uplink that queues for 300 ms under load cannot carry a game. A
|
||||
25 Mbps one with `fq_codel` or CAKE can. If your grade is C or F it is almost
|
||||
always a router setting rather than a line you need to upgrade.
|
||||
|
||||
## Options
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `--no-net` | Skip the network test (it uploads ~100 MB) |
|
||||
| `--no-steam` | Never look at Steam, and do not ask |
|
||||
| `--yes` | Take the defaults — for a second run, not a first |
|
||||
| `--json <PATH>` | Where to write the full report |
|
||||
| `--quiet` | Only the summary line, for scripting |
|
||||
| `--no-open` | Do not offer to open a browser; just print the link |
|
||||
| `--submit-url` | Where the submit link points (default `https://doctor.nestri.io`) |
|
||||
|
||||
## Verdicts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `HOST-READY` | Passes everything, and close enough to the network to serve others |
|
||||
| `HOST-READY-LOCAL` | Passes everything, but far enough out that it can only serve players nearby |
|
||||
| `HOST-NET` | Good machine; the connection is in the way |
|
||||
| `HOST-FIXABLE` | Nothing is a hardware limit — what is missing can be installed |
|
||||
| `CLIENT` | Not a host. A complete answer, and what most machines are |
|
||||
| `UNKNOWN` | A blocking check could not be run. An unknown is not a no |
|
||||
|
||||
## Sending it back
|
||||
|
||||
At the end it prints a link, lists in plain English what the link contains, and
|
||||
opens it when you press Enter. That is the whole submission — no account, no
|
||||
form, no email client.
|
||||
|
||||
One thing worth knowing if you are reading `install/install.sh`: it reopens
|
||||
stdin on `/dev/tty` before handing over. Piped into a shell the documented way,
|
||||
the script's stdin **is** the pipe and the pipe is at end of file, so without
|
||||
that line `nesdoctor` correctly sees a non-terminal stdin and skips every
|
||||
question — a run that completes, looks fine, and answers nothing. Running the
|
||||
script from a file works perfectly, which is what makes it worth a comment.
|
||||
|
||||
The link is built from **readable query parameters** rather than an encoded
|
||||
blob. A blob would be shorter and would let us send more; it would also mean you
|
||||
cannot read what you are sending, which is the one thing this program has going
|
||||
for it.
|
||||
|
||||
If you would rather not click a link we wrote, the short line is printed too and
|
||||
put on your clipboard.
|
||||
|
||||
## What it deliberately does not tell you
|
||||
|
||||
- **A pass is not a promise.** Every check is a *necessary* condition. Nothing
|
||||
here runs under load, so a machine that passes can still fail on block I/O.
|
||||
- **`vulkaninfo` reporting the encode extension is not proof the path works.**
|
||||
We have had a correct extension list over a broken path before.
|
||||
- **The host's `virglrenderer` and Mesa are not checked at all**, on purpose.
|
||||
The box carries its own inside the image it runs in, so the host's copies are
|
||||
not on the path — and a row that could only ever say "present, patch state
|
||||
unknown" told prospective hosts their machine was wrong when it was not.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
cargo build --release -p nesdoctor
|
||||
```
|
||||
|
||||
Four dependencies, three of them `serde`/`clap`/`anyhow`. Everything that could
|
||||
be done with `std` is: the VDF parser, the platform probes and the text wrapping
|
||||
are all in-tree, because a binary handed to strangers has a dependency tree that
|
||||
is part of its interface.
|
||||
|
||||
A static build, for a release someone downloads rather than compiles:
|
||||
|
||||
```
|
||||
cargo build --release -p nesdoctor --target x86_64-unknown-linux-musl
|
||||
```
|
||||
75
apps/nesdoctor/install/install.ps1
Normal file
75
apps/nesdoctor/install/install.ps1
Normal file
@@ -0,0 +1,75 @@
|
||||
# nesdoctor installer — https://doctor.nestri.io/install.ps1
|
||||
#
|
||||
# This file is the source of what that URL serves. It lives in the public
|
||||
# repository so that anyone about to run it can read it first:
|
||||
#
|
||||
# https://github.com/nestrilabs/nestri/blob/dev/apps/nesdoctor/install/install.ps1
|
||||
#
|
||||
# What it does, in order: download the matching nesdoctor.exe from GitHub
|
||||
# Releases, verify it against the published SHA256SUMS, run it, and delete it.
|
||||
# It installs nothing permanently, writes to no system directory, and needs no
|
||||
# administrator rights.
|
||||
#
|
||||
# Usage:
|
||||
# powershell -c "irm https://doctor.nestri.io/install.ps1 | iex"
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$Repo = 'nestrilabs/nestri'
|
||||
$Tag = $env:NESDOCTOR_TAG
|
||||
|
||||
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older protocols
|
||||
# on some builds, and GitHub refuses them, which surfaces as a bare
|
||||
# "underlying connection was closed".
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||
|
||||
$arch = if ([Environment]::Is64BitOperatingSystem) { 'x86_64' } else { 'x86' }
|
||||
if ($arch -ne 'x86_64') {
|
||||
throw "nesdoctor needs 64-bit Windows."
|
||||
}
|
||||
$target = 'x86_64-pc-windows-msvc'
|
||||
$asset = "nesdoctor-$target.exe"
|
||||
|
||||
$base = if ($Tag) {
|
||||
"https://github.com/$Repo/releases/download/$Tag"
|
||||
} else {
|
||||
"https://github.com/$Repo/releases/latest/download"
|
||||
}
|
||||
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("nesdoctor-" + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
$exe = Join-Path $tmp 'nesdoctor.exe'
|
||||
|
||||
try {
|
||||
Write-Host "Downloading nesdoctor ($target)..."
|
||||
# -UseBasicParsing for PowerShell 5.1, where the default wants Internet
|
||||
# Explorer's engine to be present.
|
||||
Invoke-WebRequest -Uri "$base/$asset" -OutFile $exe -UseBasicParsing
|
||||
|
||||
# A checksum fetched from the same place as the binary is not a security
|
||||
# boundary and we do not claim it is. It catches a truncated or corrupted
|
||||
# download, which is the failure that actually happens.
|
||||
try {
|
||||
$sums = Join-Path $tmp 'SHA256SUMS'
|
||||
Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile $sums -UseBasicParsing
|
||||
$want = (Select-String -Path $sums -SimpleMatch $asset |
|
||||
Select-Object -First 1).Line -split '\s+' | Select-Object -First 1
|
||||
$have = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
|
||||
if (-not $want) { throw "no checksum for $asset in SHA256SUMS" }
|
||||
if ($have -ne $want.ToLower()) {
|
||||
throw "checksum mismatch - do not run this file`n expected $want`n got $have"
|
||||
}
|
||||
Write-Host "Checksum OK."
|
||||
} catch {
|
||||
Write-Host "Could not verify checksum: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
# Called through the console host so the interactive prompts work: piping
|
||||
# this script through `iex` leaves stdin consumed, and nesdoctor would then
|
||||
# see a non-terminal stdin and skip every question.
|
||||
& $exe @args
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
125
apps/nesdoctor/install/install.sh
Executable file
125
apps/nesdoctor/install/install.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env sh
|
||||
# nesdoctor installer — https://doctor.nestri.io/install.sh
|
||||
#
|
||||
# This file is the source of what that URL serves. It lives in the public
|
||||
# repository so that anyone about to pipe it into a shell can read it first:
|
||||
#
|
||||
# https://github.com/nestrilabs/nestri/blob/dev/apps/nesdoctor/install/install.sh
|
||||
#
|
||||
# What it does, in order: work out your platform, download the matching
|
||||
# nesdoctor binary from GitHub Releases, verify it against the published
|
||||
# SHA256SUMS, run it, and delete it. It installs nothing permanently, touches
|
||||
# no system directory, and never asks for sudo.
|
||||
#
|
||||
# What nesdoctor itself does is printed when it starts.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO="nestrilabs/nestri"
|
||||
TAG="${NESDOCTOR_TAG:-}" # empty means latest
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
|
||||
say() { printf '%s\n' "$*" >&2; }
|
||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- platform ---------------------------------------------------------------
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=macos ;;
|
||||
*) die "unsupported OS: $(uname -s). nesdoctor runs on Linux, macOS and Windows." ;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=x86_64 ;;
|
||||
arm64|aarch64) arch=aarch64 ;;
|
||||
*) die "unsupported architecture: $(uname -m)" ;;
|
||||
esac
|
||||
|
||||
case "$os/$arch" in
|
||||
linux/x86_64) target=x86_64-unknown-linux-musl ;;
|
||||
macos/aarch64) target=aarch64-apple-darwin ;;
|
||||
macos/x86_64) target=x86_64-apple-darwin ;;
|
||||
linux/aarch64)
|
||||
die "no aarch64 Linux build yet. Building from source takes a minute:
|
||||
git clone --depth 1 https://github.com/$REPO && cd nestri
|
||||
cargo run --release -p nesdoctor" ;;
|
||||
*) die "no build for $os/$arch" ;;
|
||||
esac
|
||||
|
||||
ASSET="nesdoctor-$target"
|
||||
|
||||
# --- fetch ------------------------------------------------------------------
|
||||
# curl or wget, whichever is present. -f so an HTML error page is never
|
||||
# mistaken for a binary.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
get() { curl -fsSL "$1" -o "$2"; }
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
get() { wget -qO "$2" "$1"; }
|
||||
else
|
||||
die "need curl or wget"
|
||||
fi
|
||||
|
||||
if [ -n "$TAG" ]; then
|
||||
BASE="https://github.com/$REPO/releases/download/$TAG"
|
||||
else
|
||||
BASE="https://github.com/$REPO/releases/latest/download"
|
||||
fi
|
||||
|
||||
say "Downloading nesdoctor ($target)…"
|
||||
get "$BASE/$ASSET" "$TMP/$ASSET" || die "download failed. Is there a release yet? $BASE/$ASSET"
|
||||
|
||||
# --- verify -----------------------------------------------------------------
|
||||
# A checksum we fetch from the same place as the binary is not a security
|
||||
# boundary, and pretending otherwise would be worse than saying so: it catches
|
||||
# a truncated or corrupted download, which is the failure that actually
|
||||
# happens. The signed-release version of this is a later job.
|
||||
if get "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" 2>/dev/null; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
have="$(sha256sum "$TMP/$ASSET" | cut -d' ' -f1)"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
have="$(shasum -a 256 "$TMP/$ASSET" | cut -d' ' -f1)"
|
||||
else
|
||||
have=""
|
||||
fi
|
||||
if [ -n "$have" ]; then
|
||||
want="$(grep -F "$ASSET" "$TMP/SHA256SUMS" | cut -d' ' -f1 | head -n1)"
|
||||
[ -n "$want" ] || die "no checksum for $ASSET in SHA256SUMS"
|
||||
[ "$have" = "$want" ] || die "checksum mismatch — do not run this file
|
||||
expected $want
|
||||
got $have"
|
||||
say "Checksum OK."
|
||||
else
|
||||
say "No sha256 tool found; skipping verification."
|
||||
fi
|
||||
else
|
||||
say "No SHA256SUMS published; skipping verification."
|
||||
fi
|
||||
|
||||
# --- run --------------------------------------------------------------------
|
||||
chmod +x "$TMP/$ASSET"
|
||||
say ""
|
||||
|
||||
# Reopen stdin on the terminal before handing over.
|
||||
#
|
||||
# This matters more than it looks. When this script is run the documented way
|
||||
# -- `curl -fsSL url | sh` -- the shell's stdin *is* the pipe, and the pipe is
|
||||
# at end-of-file by the time we get here. nesdoctor inherits that, sees a
|
||||
# non-terminal stdin, and correctly skips every question. The result is a run
|
||||
# that completes, looks fine, and answers nothing: the exact failure the whole
|
||||
# install path exists to avoid, and it would have been invisible in testing
|
||||
# because running the script from a file works perfectly.
|
||||
#
|
||||
# /dev/tty is the controlling terminal regardless of what stdin was piped to.
|
||||
# Where there is no terminal at all -- CI, a cron job -- the redirect fails and
|
||||
# we run without it, which is the right behaviour rather than a fallback.
|
||||
# The probe runs in a subshell on purpose. A failing redirection on `exec` is
|
||||
# *fatal* to a non-interactive shell rather than merely non-zero, so testing it
|
||||
# inline killed this script outright on any machine without a controlling
|
||||
# terminal -- measured, not theorised. The parentheses contain that.
|
||||
if [ -e /dev/tty ] && (exec 3</dev/tty) 2>/dev/null; then
|
||||
exec "$TMP/$ASSET" "$@" < /dev/tty
|
||||
else
|
||||
say "(no terminal available, so the questions will be skipped)"
|
||||
exec "$TMP/$ASSET" "$@"
|
||||
fi
|
||||
204
apps/nesdoctor/src/ask.rs
Normal file
204
apps/nesdoctor/src/ask.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! The five questions.
|
||||
//!
|
||||
//! Hard cap at five, branched on what was found, and every one of them either
|
||||
//! tells us something no probe can or gates something. Three rules:
|
||||
//!
|
||||
//! - **Never ask what can be measured.** No question asks about upstream, hours
|
||||
//! powered, library size, or play schedule — those are probed. What is left is
|
||||
//! only what a machine cannot know: intent, and what someone already pays.
|
||||
//! - **Ask about the present, not intentions about the future.** Hence *"what do
|
||||
//! you pay today"* rather than *"what would you pay"* — the first is a fact
|
||||
//! and the second is a ceiling.
|
||||
//! - **Do not explain why we are asking before they answer**, because a
|
||||
//! cooperative respondent will help by giving the answer that appears wanted.
|
||||
//!
|
||||
//! Everything is skippable with Enter. A skipped answer is recorded as skipped
|
||||
//! rather than as a default, because "did not say" and "said no" are different
|
||||
//! data and collapsing them is how a survey lies.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct Answers {
|
||||
/// The one question that could change the roadmap, so it is asked first and
|
||||
/// of everybody. Answer `remote` is a product with no capacity model, no
|
||||
/// library cost, no peak and no trough — and it is what the people we can
|
||||
/// currently reach are already doing for themselves. That is the
|
||||
/// uncomfortable possibility, which is the reason to ask rather than not.
|
||||
pub want: Option<String>,
|
||||
/// USERS.md 7, roughly: is this machine a host, a client, or both?
|
||||
pub role: Option<String>,
|
||||
/// USERS.md 6: cash or credit. Only asked of a machine that could host.
|
||||
pub share_for: Option<String>,
|
||||
/// USERS.md 5, in its factual form: current spend, not willingness to pay.
|
||||
pub pays_today: Option<String>,
|
||||
/// Asked only of a non-Linux machine: is there a Linux box behind it?
|
||||
pub other_linux: Option<String>,
|
||||
/// Consent gate, not a survey question.
|
||||
pub steam_consent: bool,
|
||||
pub asked: usize,
|
||||
}
|
||||
|
||||
pub struct Ctx {
|
||||
/// Whether the blocking host checks passed.
|
||||
pub could_host: bool,
|
||||
/// Whether the machine is plausibly capable regardless of software setup —
|
||||
/// used to decide if the cash-or-credit question is worth asking at all.
|
||||
pub capable: bool,
|
||||
pub is_linux: bool,
|
||||
pub steam_present: bool,
|
||||
}
|
||||
|
||||
pub fn run(ctx: &Ctx) -> Answers {
|
||||
let mut a = Answers::default();
|
||||
println!("\n\x1b[1mFour or five questions, and Enter skips any of them.\x1b[0m");
|
||||
println!("\x1b[2mNothing here is sent anywhere. You will see the exact line before you\x1b[0m");
|
||||
println!("\x1b[2mshare it, and you can edit or discard it.\x1b[0m\n");
|
||||
|
||||
// 1 — the roadmap question. First because it is the one whose answer we
|
||||
// would most regret not having, and because a respondent who quits after
|
||||
// one question should have answered this one.
|
||||
a.want = choose(
|
||||
"If Nestri could only do one of these well, which would you want?",
|
||||
&[
|
||||
("cloud", "Play my games on your hardware, somewhere near me"),
|
||||
("remote", "Reach my own gaming PC from anywhere"),
|
||||
("both", "Both, equally"),
|
||||
("watch", "Neither — just having a look"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
|
||||
// 2 — role. Asked of everyone, because it decides what the rest means.
|
||||
a.role = choose(
|
||||
"What is this machine for?",
|
||||
&[
|
||||
("play", "Playing games on"),
|
||||
("host", "Hosting games for other people"),
|
||||
("both", "Both"),
|
||||
("look", "Just having a look"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
|
||||
// 3 — cash or credit, only where it is not a hypothetical. Asking someone
|
||||
// whose machine cannot host what they would charge for it produces noise.
|
||||
if ctx.capable && matches!(a.role.as_deref(), Some("host") | Some("both") | None) {
|
||||
a.share_for = choose(
|
||||
"If this machine served another player while you were not using it, \
|
||||
would you rather have",
|
||||
&[
|
||||
("credit", "Credit off my own subscription"),
|
||||
("cash", "Cash"),
|
||||
("either", "Either, I don't mind"),
|
||||
("no", "Neither — I would not share it"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
}
|
||||
|
||||
// 4 — current spend. The factual version of willingness to pay.
|
||||
a.pays_today = choose(
|
||||
"What do you pay a month for gaming right now, all in?",
|
||||
&[
|
||||
("0", "Nothing"),
|
||||
("1-9", "Under 10"),
|
||||
("10-19", "10 to 19"),
|
||||
("20-39", "20 to 39"),
|
||||
("40+", "40 or more"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
|
||||
// 5 — a client machine may still have a host behind it. This converts a
|
||||
// respondent who is not a candidate into a supply data point.
|
||||
if !ctx.is_linux {
|
||||
a.other_linux = choose(
|
||||
"Do you have another machine — a Linux one — that could host?",
|
||||
&[
|
||||
("yes", "Yes"),
|
||||
("could", "No, but I could set one up"),
|
||||
("no", "No"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
}
|
||||
|
||||
// The consent gate — not counted as one of the five. Last, so that by now the person has seen what this
|
||||
// program is and what it prints.
|
||||
if ctx.steam_present {
|
||||
println!();
|
||||
println!(
|
||||
"\x1b[1mOne permission.\x1b[0m Steam keeps, on this disk, the size of each game you"
|
||||
);
|
||||
println!("have installed and the time you last launched it. Reading it answers three");
|
||||
println!("things we would otherwise have to ask you badly: how big a library is, what");
|
||||
println!("shape it has, and what hours you actually play.");
|
||||
println!();
|
||||
println!(" \x1b[2mIt is read locally. Titles never appear in the shareable line — only");
|
||||
println!(" a count, a size band, and an hour histogram. You will see all of it.\x1b[0m");
|
||||
println!();
|
||||
a.steam_consent = yes_no("May I read it?", true);
|
||||
}
|
||||
|
||||
if !ctx.could_host && ctx.is_linux {
|
||||
println!(
|
||||
"\n\x1b[2m(Skipped the hosting question — the checks above say this machine cannot \
|
||||
host yet.)\x1b[0m"
|
||||
);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// A numbered single choice. Returns the stable key, not the label, so the
|
||||
/// wording can change without breaking comparisons across runs.
|
||||
fn choose(question: &str, options: &[(&str, &str)]) -> Option<String> {
|
||||
println!("\x1b[1m{question}\x1b[0m");
|
||||
for (i, (_, label)) in options.iter().enumerate() {
|
||||
println!(" {}) {}", i + 1, label);
|
||||
}
|
||||
loop {
|
||||
print!(" > ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let line = read_line();
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
println!(" \x1b[2mskipped\x1b[0m\n");
|
||||
return None;
|
||||
}
|
||||
match line.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= options.len() => {
|
||||
println!();
|
||||
return Some(options[n - 1].0.to_string());
|
||||
}
|
||||
_ => println!(" \x1b[2m1–{}, or Enter to skip\x1b[0m", options.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(question: &str, default_yes: bool) -> bool {
|
||||
let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
|
||||
loop {
|
||||
print!("\x1b[1m{question}\x1b[0m {hint} ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let line = read_line();
|
||||
match line.trim().to_ascii_lowercase().as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
"" => return default_yes,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_line() -> String {
|
||||
let mut s = String::new();
|
||||
// EOF (a piped stdin, or Ctrl-D) reads as a skip rather than as an error:
|
||||
// this binary should still produce a report when run non-interactively.
|
||||
if std::io::stdin().lock().read_line(&mut s).is_err() {
|
||||
return String::new();
|
||||
}
|
||||
s
|
||||
}
|
||||
321
apps/nesdoctor/src/hostreq.rs
Normal file
321
apps/nesdoctor/src/hostreq.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Can this machine run a box?
|
||||
//!
|
||||
//! One check per hard requirement for running a box, in the order our host
|
||||
//! requirements list them. This is the first thing that has ever *executed* that
|
||||
//! list — until now a host was qualified by a human reading a table, and a
|
||||
//! requirement nothing can check is a requirement that is silently optional.
|
||||
//!
|
||||
//! Three deliberate limits, stated because a green result here is not a promise:
|
||||
//!
|
||||
//! - **`vulkaninfo` is not sufficient by itself.** The contract says so twice,
|
||||
//! we have had the case that proves it: extension present, path still broken. So the encode row reports what the extension list says
|
||||
//! and labels it as such.
|
||||
//! - **The renderer is not checked at all, on purpose.** It used to be, and the
|
||||
//! row could only ever say "present, patch state unknown" — which is a row
|
||||
//! that cannot pass. The box now carries its own virglrenderer and Mesa
|
||||
//! inside the image it runs in, so the host's copies are not on the path and
|
||||
//! asking about them told a prospective host their machine was wrong when it
|
||||
//! was not.
|
||||
//! - **Nothing here is measured under load.** A host that passes every row can
|
||||
//! still fail on block I/O, which is the real density ceiling and needs a
|
||||
//! benchmark rather than a probe.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::sys::{self, SysInfo};
|
||||
|
||||
/// A single requirement's outcome.
|
||||
///
|
||||
/// `Unknown` is a first-class result and is never collapsed into `Fail`. The
|
||||
/// difference matters: a failed check is a machine that cannot host, an unknown
|
||||
/// one is a machine we could not ask, and reporting the second as the first is
|
||||
/// how you lose a capable host to a missing `lspci`.
|
||||
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum State {
|
||||
Pass,
|
||||
Fail,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Check {
|
||||
pub id: &'static str,
|
||||
pub what: &'static str,
|
||||
pub state: State,
|
||||
pub detail: String,
|
||||
/// True when a `Fail` here means the machine cannot host at all.
|
||||
pub blocking: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HostReport {
|
||||
pub checks: Vec<Check>,
|
||||
/// `true` only when no blocking check failed.
|
||||
pub could_host: bool,
|
||||
/// Blocking checks we could not determine. A host with these is a
|
||||
/// *maybe*, and saying so is the point.
|
||||
pub unknowns: usize,
|
||||
}
|
||||
|
||||
pub fn probe(sys: &SysInfo) -> HostReport {
|
||||
let mut c: Vec<Check> = Vec::new();
|
||||
|
||||
// Not Linux: every row below is meaningless, and pretending otherwise
|
||||
// produces a page of red for a machine that was never a candidate. A
|
||||
// Windows box is a *client*, which is a perfectly good thing to be.
|
||||
if sys.os != "linux" {
|
||||
c.push(Check {
|
||||
id: "os",
|
||||
what: "Linux with KVM",
|
||||
state: State::Fail,
|
||||
detail: format!(
|
||||
"this is {}. A host must be Linux; nesbox is a microVM hypervisor. \
|
||||
As a client, this machine is fine and nothing below applies.",
|
||||
sys.os
|
||||
),
|
||||
blocking: true,
|
||||
});
|
||||
return HostReport {
|
||||
could_host: false,
|
||||
unknowns: 0,
|
||||
checks: c,
|
||||
};
|
||||
}
|
||||
|
||||
// --- KVM -------------------------------------------------------------
|
||||
let kvm = sys::exists("/dev/kvm");
|
||||
let kvm_rw = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/kvm")
|
||||
.is_ok();
|
||||
c.push(Check {
|
||||
id: "kvm",
|
||||
what: "/dev/kvm present and openable",
|
||||
state: if kvm_rw { State::Pass } else { State::Fail },
|
||||
detail: match (kvm, kvm_rw) {
|
||||
(true, true) => "yes".into(),
|
||||
(true, false) => {
|
||||
"present but not openable — you are not in the `kvm` group, or virtualisation \
|
||||
is disabled in firmware"
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
"missing — enable SVM/VT-x in firmware, or this is a VM without nested virt".into()
|
||||
}
|
||||
},
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
// --- GPU vendor and render node --------------------------------------
|
||||
let usable: Vec<_> = sys
|
||||
.gpus
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.render_node.is_some() && matches!(g.vendor.as_deref(), Some("AMD") | Some("Intel"))
|
||||
})
|
||||
.collect();
|
||||
let nvidia_only = !sys.gpus.is_empty()
|
||||
&& sys
|
||||
.gpus
|
||||
.iter()
|
||||
.all(|g| g.vendor.as_deref() == Some("NVIDIA"));
|
||||
c.push(Check {
|
||||
id: "gpu",
|
||||
what: "an AMD or Intel GPU with a DRM render node",
|
||||
state: if !usable.is_empty() {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Fail
|
||||
},
|
||||
detail: if let Some(g) = usable.first() {
|
||||
format!(
|
||||
"{} at {}",
|
||||
g.name,
|
||||
g.render_node.clone().unwrap_or_default()
|
||||
)
|
||||
} else if nvidia_only {
|
||||
"NVIDIA only. Nvidia needs virtio-nvgpu, which is not funded — so this card \
|
||||
cannot host today. It is a fine client."
|
||||
.into()
|
||||
} else if sys.gpus.is_empty() {
|
||||
"no GPU found under /sys/class/drm".into()
|
||||
} else {
|
||||
format!(
|
||||
"found {} but none with both a supported vendor and a render node",
|
||||
sys.gpus.len()
|
||||
)
|
||||
},
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
// --- Vulkan Video encode ---------------------------------------------
|
||||
// The contract is explicit that this is stricter than "has a hardware
|
||||
// encoder": VA-API and NVENC are far more common than this extension, so a
|
||||
// machine can encode well and still fail.
|
||||
// `--summary` is NOT usable here and the order used to be the other way
|
||||
// round. Measured on the development laptop 2026-09-02: `vulkaninfo
|
||||
// --summary` lists **zero** `VK_KHR_video*` entries while full `vulkaninfo`
|
||||
// lists five on the same machine — so preferring the summary reported "not
|
||||
// advertised" on a card that advertises it, which is a false negative on
|
||||
// the one check most likely to disqualify a host.
|
||||
let vk = sys::sh("vulkaninfo", &[]).unwrap_or_default();
|
||||
let has_encode_q = vk.contains("VK_KHR_video_encode_queue");
|
||||
let has_codec = vk.contains("VK_KHR_video_encode_h264")
|
||||
|| vk.contains("VK_KHR_video_encode_h265")
|
||||
|| vk.contains("VK_KHR_video_encode_av1");
|
||||
c.push(Check {
|
||||
id: "vkvideo",
|
||||
what: "VK_KHR_video_encode_queue plus a codec extension",
|
||||
state: if vk.is_empty() {
|
||||
State::Unknown
|
||||
} else if has_encode_q && has_codec {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Fail
|
||||
},
|
||||
detail: if vk.is_empty() {
|
||||
"vulkaninfo not installed, so this could not be checked. Install `vulkan-tools`.".into()
|
||||
} else if has_encode_q && has_codec {
|
||||
"extensions present. Note: presence is not proof — a working extension list with a \
|
||||
broken path has happened here before, so this row is a necessary and not a \
|
||||
sufficient condition."
|
||||
.into()
|
||||
} else if has_encode_q {
|
||||
"encode queue present but no codec extension found".into()
|
||||
} else {
|
||||
"not advertised. This is stricter than 'has a hardware encoder': VA-API and NVENC \
|
||||
are much more common than this extension."
|
||||
.into()
|
||||
},
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
// --- two stores -------------------------------------------------------
|
||||
// ZFS for content, direct-I/O-capable for box images, and not the same
|
||||
// filesystem, because ZFS ignores `O_DIRECT`.
|
||||
let zfs_mounts: Vec<&crate::sys::Disk> = sys
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| d.fs.as_deref() == Some("zfs"))
|
||||
.collect();
|
||||
c.push(Check {
|
||||
id: "content-store",
|
||||
what: "a ZFS pool for game datasets",
|
||||
state: if zfs_mounts.is_empty() {
|
||||
State::Fail
|
||||
} else {
|
||||
State::Pass
|
||||
},
|
||||
detail: if let Some(d) = zfs_mounts.first() {
|
||||
format!("{} ({:.0} GiB free)", d.mount, d.free_gib)
|
||||
} else {
|
||||
"no ZFS mount found. One dataset per game, cloned per player, is the whole of the \
|
||||
content store — no other filesystem gives clones and send/recv."
|
||||
.into()
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// "Its own device" is part of the requirement, not a nicety: separate
|
||||
// devices keep a box's disk latency out of a game download's write path.
|
||||
//
|
||||
// Compared by *physical* device, not by the `df` source string. Two
|
||||
// partitions of one SSD are two strings and one queue, and two LVM logical
|
||||
// volumes on one disk look entirely unrelated — so a string comparison
|
||||
// passes exactly the topology this requirement exists to reject.
|
||||
let root_phys: Vec<String> = sys
|
||||
.disks
|
||||
.iter()
|
||||
.find(|d| d.mount == "/")
|
||||
.and_then(|d| d.source.as_deref())
|
||||
.map(sys::physical_devices)
|
||||
.unwrap_or_default();
|
||||
let box_store = sys.disks.iter().find(|d| {
|
||||
let Some(src) = d.source.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let phys = sys::physical_devices(src);
|
||||
matches!(d.fs.as_deref(), Some("ext4") | Some("xfs"))
|
||||
&& d.mount != "/"
|
||||
&& d.free_gib >= 64.0
|
||||
// No overlap with whatever carries the root filesystem.
|
||||
&& !phys.iter().any(|p| root_phys.contains(p))
|
||||
});
|
||||
c.push(Check {
|
||||
id: "box-store",
|
||||
what: "ext4 or xfs, not /, for box images (O_DIRECT)",
|
||||
state: match box_store {
|
||||
Some(_) => State::Pass,
|
||||
None => State::Fail,
|
||||
},
|
||||
detail: match box_store {
|
||||
Some(d) => format!(
|
||||
"{} on {} ({:.0} GiB free)",
|
||||
d.mount,
|
||||
d.fs.clone().unwrap_or_default(),
|
||||
d.free_gib
|
||||
),
|
||||
None => "none found. A box image must be openable O_DIRECT or the box has no storage \
|
||||
bound at all — ZFS ignores the flag, and a warm page cache let a capped \
|
||||
guest read at 13.3 GB/s against a 20 MB/s cap. Games are hundreds of GiB, \
|
||||
so / is not an option either."
|
||||
.into(),
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// --- cgroup io delegation --------------------------------------------
|
||||
// `io.max` is applied per box, and a user session gets `cpu memory pids`
|
||||
// but not `io` — so an unprivileged bound has nothing to attach to.
|
||||
let io_ctrl = std::fs::read_to_string("/sys/fs/cgroup/cgroup.controllers")
|
||||
.map(|s| s.split_whitespace().any(|w| w == "io"))
|
||||
.unwrap_or(false);
|
||||
c.push(Check {
|
||||
id: "cgroup-io",
|
||||
what: "the io cgroup controller available",
|
||||
state: if io_ctrl { State::Pass } else { State::Fail },
|
||||
detail: if io_ctrl {
|
||||
"present at the root".into()
|
||||
} else {
|
||||
"not available. Without it a per-box io.max silently has nothing to attach to.".into()
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// --- virtiofsd --------------------------------------------------------
|
||||
let virtiofsd = [
|
||||
"/usr/bin/virtiofsd",
|
||||
"/usr/libexec/virtiofsd",
|
||||
"/usr/lib/virtiofsd",
|
||||
]
|
||||
.iter()
|
||||
.find(|p| sys::exists(p));
|
||||
c.push(Check {
|
||||
id: "virtiofsd",
|
||||
what: "virtiofsd, for shared directories into the guest",
|
||||
state: if virtiofsd.is_some() {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Unknown
|
||||
},
|
||||
detail: match virtiofsd {
|
||||
Some(p) => (*p).to_string(),
|
||||
None => "not found in the usual places; it may still be packaged elsewhere".into(),
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
let unknowns = c
|
||||
.iter()
|
||||
.filter(|k| k.blocking && k.state == State::Unknown)
|
||||
.count();
|
||||
let could_host = !c.iter().any(|k| k.blocking && k.state == State::Fail);
|
||||
|
||||
HostReport {
|
||||
checks: c,
|
||||
could_host,
|
||||
unknowns,
|
||||
}
|
||||
}
|
||||
429
apps/nesdoctor/src/main.rs
Normal file
429
apps/nesdoctor/src/main.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
//! nesdoctor — is this machine any good, as a Nestri host or as a client?
|
||||
//!
|
||||
//! # What it is
|
||||
//!
|
||||
//! The first executable form of our host requirements. Until now a host was
|
||||
//! qualified by a human reading a table, and a requirement nothing can check is
|
||||
//! one that is silently optional. It is also the qualification anyone offering a
|
||||
//! machine has to pass — and the check that decides it is loaded latency, not
|
||||
//! throughput.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! nesdoctor [OPTIONS]
|
||||
//!
|
||||
//! --no-net Skip the network test (it uploads ~100 MB)
|
||||
//! --no-steam Never look at Steam, and do not ask
|
||||
//! --yes Accept the prompts; still prints, still saves nothing remote
|
||||
//! --json <PATH> Where to write the full report [default: ./nesdoctor.json]
|
||||
//! --quiet Only the summary line, for scripting
|
||||
//! ```
|
||||
//!
|
||||
//! # What it does not do
|
||||
//!
|
||||
//! **It has no server.** Nothing is uploaded, no telemetry endpoint exists, and
|
||||
//! there is no build of this program that reports home — the network test talks
|
||||
//! to Cloudflare's public speed-test sink and to `1.1.1.1`, neither of which is
|
||||
//! ours. The output is a line on your terminal. If you want us to have it, you
|
||||
//! paste it somewhere; if you do not, we never had it.
|
||||
//!
|
||||
//! That is a design decision and not a promise about our intentions: there is
|
||||
//! nothing to trust, because there is nothing to switch on later.
|
||||
//!
|
||||
//! # Why anyone would run it
|
||||
//!
|
||||
//! Because of one number almost nobody has ever seen: how much latency their
|
||||
//! own connection adds when it is busy. Throughput is the figure everybody
|
||||
//! knows and it is the wrong one — a 500 Mbps uplink that queues for 300 ms
|
||||
//! under load cannot carry an interactive stream, and a 25 Mbps one with
|
||||
//! `fq_codel` can.
|
||||
|
||||
mod ask;
|
||||
mod hostreq;
|
||||
mod net;
|
||||
mod report;
|
||||
mod steam;
|
||||
mod sys;
|
||||
mod vdf;
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "nesdoctor",
|
||||
about = "Is this machine any good, as a Nestri host or as a client?",
|
||||
version
|
||||
)]
|
||||
struct Args {
|
||||
/// Skip the network test. It uploads roughly 100 MB to a public speed-test
|
||||
/// sink; on a metered connection that is worth knowing about up front.
|
||||
#[arg(long)]
|
||||
no_net: bool,
|
||||
|
||||
/// Never read Steam, and do not ask about it.
|
||||
#[arg(long)]
|
||||
no_steam: bool,
|
||||
|
||||
/// Answer the prompts affirmatively and take the defaults. Intended for a
|
||||
/// second run, not a first one — the questions are the point.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
|
||||
/// Where to write the full report.
|
||||
#[arg(long, default_value = "nesdoctor.json")]
|
||||
json: PathBuf,
|
||||
|
||||
/// Print only the summary line.
|
||||
#[arg(long)]
|
||||
quiet: bool,
|
||||
|
||||
/// Where the submit link points. Override to test against a local worker.
|
||||
#[arg(long, default_value = "https://doctor.nestri.io")]
|
||||
submit_url: String,
|
||||
|
||||
/// Do not offer to open a browser; just print the link.
|
||||
#[arg(long)]
|
||||
no_open: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.quiet {
|
||||
banner();
|
||||
}
|
||||
|
||||
// --- what the machine is ---------------------------------------------
|
||||
if !args.quiet {
|
||||
net::tick(" reading hardware…");
|
||||
}
|
||||
let sys = sys::probe();
|
||||
if !args.quiet {
|
||||
println!(" done");
|
||||
print_sys(&sys);
|
||||
}
|
||||
|
||||
// --- can it host ------------------------------------------------------
|
||||
let host = hostreq::probe(&sys);
|
||||
if !args.quiet {
|
||||
report::print_checks(&host);
|
||||
}
|
||||
|
||||
// --- the network ------------------------------------------------------
|
||||
let netr = if args.no_net {
|
||||
net::NetReport::unmeasured("skipped with --no-net")
|
||||
} else {
|
||||
if !args.quiet {
|
||||
println!("\n\x1b[1mNetwork\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m About 20 seconds. This saturates your upload on purpose — that is the\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m only way to see the number that matters — so expect a video call to\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m stutter while it runs. Roughly 100 MB of upload.\x1b[0m");
|
||||
net::tick(" measuring…");
|
||||
}
|
||||
let r = net::run();
|
||||
if !args.quiet {
|
||||
println!(" done");
|
||||
print_net(&r);
|
||||
}
|
||||
r
|
||||
};
|
||||
|
||||
let region = if args.no_net {
|
||||
None
|
||||
} else {
|
||||
net::region_hint()
|
||||
};
|
||||
|
||||
// --- the questions ----------------------------------------------------
|
||||
let steam_present = !args.no_steam && steam::present();
|
||||
let capable = sys.os == "linux"
|
||||
&& sys
|
||||
.gpus
|
||||
.iter()
|
||||
.any(|g| matches!(g.vendor.as_deref(), Some("AMD") | Some("Intel")));
|
||||
|
||||
// Do not ask questions that cannot be answered. `--quiet` promises a single
|
||||
// parseable line, and a prompt written to stdout breaks that promise; a
|
||||
// non-terminal stdin cannot answer at all, so prompting into it just prints
|
||||
// the whole questionnaire and skips every item. Both cases used to print
|
||||
// three questions and then "skipped" -- found by the CI smoke test, which
|
||||
// is the only reason anything runs this way.
|
||||
let interactive = !args.quiet && std::io::stdin().is_terminal();
|
||||
|
||||
let answers = if args.yes || !interactive {
|
||||
ask::Answers {
|
||||
// `--yes` is a deliberate consent; a pipe is not consent to read
|
||||
// somebody's library.
|
||||
steam_consent: args.yes && steam_present,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ask::run(&ask::Ctx {
|
||||
could_host: host.could_host,
|
||||
capable,
|
||||
is_linux: sys.os == "linux",
|
||||
steam_present,
|
||||
})
|
||||
};
|
||||
|
||||
// --- Steam, only with a yes -------------------------------------------
|
||||
let steamr = if answers.steam_consent {
|
||||
let r = steam::read();
|
||||
if !args.quiet {
|
||||
print_steam(&r);
|
||||
}
|
||||
r
|
||||
} else {
|
||||
steam::SteamReport::default()
|
||||
};
|
||||
|
||||
// --- verdict ----------------------------------------------------------
|
||||
let v = report::verdict(&sys, &host, &netr);
|
||||
if !args.quiet {
|
||||
report::print_verdict(v, &netr);
|
||||
}
|
||||
|
||||
let full = report::Full {
|
||||
nesdoctor: report::VERSION,
|
||||
sys: &sys,
|
||||
host: &host,
|
||||
net: &netr,
|
||||
steam: &steamr,
|
||||
answers: &answers,
|
||||
verdict: v,
|
||||
region_hint: region.clone(),
|
||||
};
|
||||
let line = report::summary_line(&full);
|
||||
|
||||
// --- the full report, locally -----------------------------------------
|
||||
let wrote = serde_json::to_string_pretty(&full)
|
||||
.ok()
|
||||
.and_then(|j| std::fs::write(&args.json, j).ok().map(|_| ()))
|
||||
.is_some();
|
||||
|
||||
if args.quiet {
|
||||
println!("{line}");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = report::submit_url(&args.submit_url, &full);
|
||||
|
||||
println!();
|
||||
println!("\x1b[1m─── One keystroke and we are done ───────────────────────────────\x1b[0m");
|
||||
println!();
|
||||
println!("\x1b[2m Everything above goes to us through this link. It contains:\x1b[0m");
|
||||
for item in report::submit_contents(&steamr, &answers) {
|
||||
println!("\x1b[2m · {item}\x1b[0m");
|
||||
}
|
||||
println!();
|
||||
println!("\x1b[2m {}\x1b[0m", args.submit_url);
|
||||
println!();
|
||||
|
||||
let opened = if args.no_open || !interactive {
|
||||
false
|
||||
} else {
|
||||
print!(
|
||||
"\x1b[1;97;44m Press Enter to send it \x1b[0m\x1b[2m (or Ctrl-C to send nothing) \x1b[0m"
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut s = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut s);
|
||||
println!();
|
||||
report::open_in_browser(&url)
|
||||
};
|
||||
|
||||
if opened {
|
||||
println!("\x1b[32m ✓ Opened in your browser. That is it — thank you.\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m If the page did not load, the link is below and it still works later.\x1b[0m"
|
||||
);
|
||||
} else {
|
||||
println!("\x1b[1m Open this to send it:\x1b[0m");
|
||||
}
|
||||
println!();
|
||||
println!("\x1b[4;36m{url}\x1b[0m");
|
||||
println!();
|
||||
|
||||
// The clipboard line stays as the offline path: a headless host, a machine
|
||||
// with no browser, or somebody who would rather paste into a channel than
|
||||
// click a link we wrote.
|
||||
let clip = report::to_clipboard(&line);
|
||||
println!("\x1b[2m Prefer to paste it yourself? The short version:\x1b[0m");
|
||||
println!();
|
||||
println!(" {line}");
|
||||
if let Some(tool) = clip {
|
||||
println!("\x1b[2m (also on your clipboard, via {tool})\x1b[0m");
|
||||
}
|
||||
|
||||
if wrote {
|
||||
println!();
|
||||
println!("\x1b[1m And if you feel like being properly helpful\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m {} has the long version — every check with its reason, the\x1b[0m",
|
||||
args.json.display()
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m full latency series, and your installed titles with sizes and launch\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m times. It is more useful to us than anything above, because it is what\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m lets us size a real game library. Have a read and send it along if\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m nothing in there bothers you.\x1b[0m");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn banner() {
|
||||
println!();
|
||||
println!("\x1b[1mnesdoctor {}\x1b[0m", report::VERSION);
|
||||
println!("\x1b[2mChecks whether this machine can host a Nestri box, measures what your\x1b[0m");
|
||||
println!("\x1b[2mconnection actually does under load, and asks at most five questions.\x1b[0m");
|
||||
println!();
|
||||
println!(
|
||||
"\x1b[2mNothing is uploaded. There is no server to upload to — the output is a\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2mline on your terminal that you may choose to paste somewhere.\x1b[0m");
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_sys(s: &sys::SysInfo) {
|
||||
println!("\n\x1b[1mMachine\x1b[0m");
|
||||
println!(
|
||||
" {} {} · {}",
|
||||
s.os,
|
||||
s.arch,
|
||||
s.release.clone().unwrap_or_else(|| "unknown".into())
|
||||
);
|
||||
if let Some(c) = &s.cpu_model {
|
||||
println!(
|
||||
" {c} · {} threads · {} RAM",
|
||||
s.cpu_threads,
|
||||
s.ram_gib.map_or("?".into(), |g| format!("{g:.0} GiB"))
|
||||
);
|
||||
}
|
||||
for g in &s.gpus {
|
||||
println!(
|
||||
" {}{}",
|
||||
g.name,
|
||||
g.render_node
|
||||
.as_ref()
|
||||
.map(|r| format!(" · {r}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
for d in s.disks.iter().take(3) {
|
||||
println!(
|
||||
" {} · {} · {:.0} GiB free",
|
||||
d.mount,
|
||||
d.fs.clone().unwrap_or_else(|| "?".into()),
|
||||
d.free_gib
|
||||
);
|
||||
}
|
||||
match (s.powered_hours_per_day, s.powered_span_days) {
|
||||
(Some(h), Some(days)) => println!(
|
||||
" powered {h:.1} h/day, averaged over {days:.0} days of boot history\n \
|
||||
\x1b[2m(measured, not asked — it counts powered rather than idle, so read it as \
|
||||
'always on' vs 'evenings')\x1b[0m"
|
||||
),
|
||||
_ => println!(" \x1b[2mno boot history available, so hours-powered is unknown\x1b[0m"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_net(n: &net::NetReport) {
|
||||
let f = |o: Option<f64>, unit: &str| {
|
||||
o.map(|v| format!("{v:.0}{unit}"))
|
||||
.unwrap_or_else(|| "—".into())
|
||||
};
|
||||
println!(" upstream {}", f(n.upstream_mbps, " Mbps"));
|
||||
println!(" latency, idle {}", f(n.idle_rtt_ms, " ms"));
|
||||
if n.idle_rtt_ms.is_some_and(|r| r > 60.0) {
|
||||
println!(
|
||||
" \x1b[2m That is the round trip to the *nearest* major network, so it is a\x1b[0m"
|
||||
);
|
||||
println!(" \x1b[2m floor on what any player sees. It is distance, not a fault.\x1b[0m");
|
||||
}
|
||||
println!(" latency, loaded {}", f(n.loaded_rtt_ms, " ms"));
|
||||
println!(" latency, loaded p95 {}", f(n.loaded_rtt_p95_ms, " ms"));
|
||||
match (n.bloat_ms, n.grade) {
|
||||
(Some(b), Some(g)) => {
|
||||
let colour = match g {
|
||||
"A" => "32",
|
||||
"B" => "32",
|
||||
"C" => "33",
|
||||
_ => "31",
|
||||
};
|
||||
println!(" \x1b[1madded under load \x1b[{colour}m+{b:.0} ms grade {g}\x1b[0m");
|
||||
println!(
|
||||
" \x1b[2m A/B is fine, C is marginal, F cannot carry an interactive stream.\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
" \x1b[2m The whole network allowance is about 40 ms, because render, encode,\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
" \x1b[2m decode, display and the jitter buffer have already spent ~58 ms.\x1b[0m"
|
||||
);
|
||||
}
|
||||
_ => println!(" added under load —"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_steam(s: &steam::SteamReport) {
|
||||
if !s.found {
|
||||
return;
|
||||
}
|
||||
println!("\n\x1b[1mSteam\x1b[0m");
|
||||
println!(
|
||||
" {} titles installed · {:.0} GiB on disk",
|
||||
s.titles,
|
||||
steam::gib(s.bytes_on_disk)
|
||||
);
|
||||
for (name, bytes) in &s.largest {
|
||||
println!(" {:>6.0} GiB {}", steam::gib(*bytes), name);
|
||||
}
|
||||
if s.profiles > 1 {
|
||||
println!(
|
||||
" \x1b[2m{} Steam profiles here — the hours below are the busiest one, not all of\x1b[0m",
|
||||
s.profiles
|
||||
);
|
||||
println!(" \x1b[2mthem added together, which would be a histogram of nobody.\x1b[0m");
|
||||
}
|
||||
if s.launch_samples > 0 {
|
||||
println!(
|
||||
"\n When you launch games — {} launch records{}, local time:",
|
||||
s.launch_samples,
|
||||
if s.launches_uninstalled > 0 {
|
||||
format!(
|
||||
", {} of them games you no longer have installed",
|
||||
s.launches_uninstalled
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
println!(" {}", steam::sparkline(&s.launch_hours));
|
||||
println!(" \x1b[2m0h 6h 12h 18h 23h\x1b[0m");
|
||||
if let Some((a, b)) = s.peak_window {
|
||||
println!(" Half of your launches fall between \x1b[1m{a:02}:00 and {b:02}:59\x1b[0m.");
|
||||
let width = if b >= a { b - a + 1 } else { 24 - a + b + 1 };
|
||||
if width <= 6 {
|
||||
println!(
|
||||
" \x1b[2mThat is a narrow window, which is what a peak looks like.\x1b[0m"
|
||||
);
|
||||
} else {
|
||||
println!(" \x1b[2mThat is spread out — no strong peak.\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
284
apps/nesdoctor/src/net.rs
Normal file
284
apps/nesdoctor/src/net.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! The network test, and the reason anybody runs this.
|
||||
//!
|
||||
//! Two numbers, and the second is the one that matters:
|
||||
//!
|
||||
//! 1. **Sustained upstream**, because a session is video going the unusual way
|
||||
//! down a domestic line.
|
||||
//! 2. **Added latency under load** — bufferbloat. A saturated consumer uplink
|
||||
//! adds 100–500 ms of queueing delay unless the router runs `fq_codel` or
|
||||
//! CAKE, and against a click-to-photon budget that has already spent ~58 ms
|
||||
//! on render, encode, decode, display and jitter buffer, **that is more than
|
||||
//! the entire remaining allowance.**
|
||||
//!
|
||||
//! So a peer with 500 Mbps up and no queue management is unusable, and one with
|
||||
//! 25 Mbps and CAKE is fine. Throughput is the number everyone volunteers and
|
||||
//! it is the wrong one. This is also why the test is worth running for its own
|
||||
//! sake: almost nobody has ever seen their own figure.
|
||||
//!
|
||||
//! Method: measure TCP connect time (one round trip, no privileges, no ICMP)
|
||||
//! to a fixed anycast address; then saturate the uplink with parallel HTTPS
|
||||
//! uploads and measure the same thing again. The difference is the queue.
|
||||
|
||||
use std::io::Write;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Cloudflare's speed-test sink. Chosen because it is anycast (so the path is
|
||||
/// short from anywhere, which keeps this a test of the *access* link rather
|
||||
/// than of the distance to us), it discards uploads, and it is not ours — we
|
||||
/// have no server to keep alive and no data arrives anywhere we control.
|
||||
const UPLOAD_URL: &str = "https://speed.cloudflare.com/__up";
|
||||
|
||||
/// Latency target. `1.1.1.1:443` answers a TCP handshake from essentially
|
||||
/// everywhere and is anycast for the same reason as above.
|
||||
const PROBE_ADDR: &str = "1.1.1.1:443";
|
||||
|
||||
/// How long to hold the uplink saturated. Long enough for a queue to fill —
|
||||
/// a short burst measures nothing, because bufferbloat is a steady-state
|
||||
/// property — and short enough not to ruin someone's evening.
|
||||
const LOAD_SECONDS: u64 = 8;
|
||||
|
||||
/// Every HTTP call gets a hard ceiling.
|
||||
///
|
||||
/// Without one, an intermediary that accepts a connection and then stalls
|
||||
/// leaves `send` blocked forever: the stop flag cannot interrupt a blocking
|
||||
/// call, so the joins below never return and **nesdoctor never prints its
|
||||
/// report at all.** A hang is the worst outcome available to a program someone
|
||||
/// runs once, because there is no second chance to ask them.
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const STREAMS: usize = 3;
|
||||
const CHUNK: usize = 1 << 20; // 1 MiB per write
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NetReport {
|
||||
pub idle_rtt_ms: Option<f64>,
|
||||
pub loaded_rtt_ms: Option<f64>,
|
||||
pub loaded_rtt_p95_ms: Option<f64>,
|
||||
/// Loaded minus idle: the queue, in milliseconds.
|
||||
pub bloat_ms: Option<f64>,
|
||||
pub upstream_mbps: Option<f64>,
|
||||
/// A, B, C or F. See [`grade`].
|
||||
pub grade: Option<&'static str>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
impl NetReport {
|
||||
/// A report with no numbers, carrying the reason. Used both for `--no-net`
|
||||
/// and for a genuine failure, because the caller has to render the same
|
||||
/// "we do not know" either way.
|
||||
pub fn unmeasured(note: &str) -> Self {
|
||||
Self::unavailable(note)
|
||||
}
|
||||
|
||||
fn unavailable(note: &str) -> Self {
|
||||
Self {
|
||||
idle_rtt_ms: None,
|
||||
loaded_rtt_ms: None,
|
||||
loaded_rtt_p95_ms: None,
|
||||
bloat_ms: None,
|
||||
upstream_mbps: None,
|
||||
grade: None,
|
||||
note: note.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bufferbloat grade, on the added-latency thresholds that matter to us rather
|
||||
/// than to a generic speed test.
|
||||
///
|
||||
/// The bands come from the frame budget, not from convention: ~40 ms of RTT is
|
||||
/// the whole network allowance for a 100 ms click-to-photon target, so 30 ms of
|
||||
/// *added* delay has already eaten most of it.
|
||||
fn grade(bloat_ms: f64) -> &'static str {
|
||||
match bloat_ms {
|
||||
b if b < 15.0 => "A",
|
||||
b if b < 30.0 => "B",
|
||||
b if b < 80.0 => "C",
|
||||
_ => "F",
|
||||
}
|
||||
}
|
||||
|
||||
/// One agent for every request this module makes, carrying the timeout.
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_global(Some(HTTP_TIMEOUT))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn run() -> NetReport {
|
||||
let Some(addr) = resolve(PROBE_ADDR) else {
|
||||
return NetReport::unavailable("could not resolve the latency probe address — offline?");
|
||||
};
|
||||
|
||||
// --- idle baseline ---------------------------------------------------
|
||||
let idle = sample_rtt(addr, 12, Duration::from_millis(120));
|
||||
let Some(idle_p50) = percentile(&idle, 0.50) else {
|
||||
return NetReport::unavailable(
|
||||
"no TCP handshake completed to 1.1.1.1:443 — a firewall may block it, so the \
|
||||
latency half could not run",
|
||||
);
|
||||
};
|
||||
|
||||
// --- saturate, and measure again -------------------------------------
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let sent = Arc::new(AtomicU64::new(0));
|
||||
let started = Instant::now();
|
||||
|
||||
let uploaders: Vec<_> = (0..STREAMS)
|
||||
.map(|_| {
|
||||
let stop = Arc::clone(&stop);
|
||||
let sent = Arc::clone(&sent);
|
||||
let ag = agent();
|
||||
std::thread::spawn(move || upload_until(&ag, &stop, &sent))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Give the queue a moment to actually fill before sampling: measuring from
|
||||
// t=0 averages in the unloaded state and understates the bloat.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
let loaded = sample_rtt(
|
||||
addr,
|
||||
(LOAD_SECONDS as usize - 2) * 4,
|
||||
Duration::from_millis(250),
|
||||
);
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let mut any_upload_ok = false;
|
||||
for h in uploaders {
|
||||
any_upload_ok |= h.join().unwrap_or(false);
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed().as_secs_f64() - 1.5;
|
||||
let bytes = sent.load(Ordering::Relaxed) as f64;
|
||||
let upstream_mbps = (any_upload_ok && elapsed > 1.0 && bytes > 0.0)
|
||||
.then(|| bytes * 8.0 / elapsed / 1_000_000.0);
|
||||
|
||||
let loaded_p50 = percentile(&loaded, 0.50);
|
||||
let loaded_p95 = percentile(&loaded, 0.95);
|
||||
let bloat = loaded_p50.map(|l| (l - idle_p50).max(0.0));
|
||||
|
||||
let note = match (&upstream_mbps, &bloat) {
|
||||
(None, _) => "upstream could not be measured (the upload sink was unreachable), so the \
|
||||
latency figures below are not under a known load and should be ignored"
|
||||
.to_string(),
|
||||
(Some(_), Some(b)) if *b >= 80.0 => {
|
||||
"your uplink queues badly under load. This is a router setting, not a line \
|
||||
problem: fq_codel or CAKE on the upload direction usually fixes it entirely."
|
||||
.to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
NetReport {
|
||||
idle_rtt_ms: Some(idle_p50),
|
||||
loaded_rtt_ms: loaded_p50,
|
||||
loaded_rtt_p95_ms: loaded_p95,
|
||||
bloat_ms: bloat,
|
||||
upstream_mbps,
|
||||
grade: bloat.map(grade),
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(s: &str) -> Option<SocketAddr> {
|
||||
s.to_socket_addrs().ok()?.next()
|
||||
}
|
||||
|
||||
/// `n` TCP handshakes, spaced by `gap`, in milliseconds. Failures are dropped
|
||||
/// rather than recorded as a large value: a refused connection is not a slow
|
||||
/// one, and averaging the two produces a number that means nothing.
|
||||
fn sample_rtt(addr: SocketAddr, n: usize, gap: Duration) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let t = Instant::now();
|
||||
if TcpStream::connect_timeout(&addr, Duration::from_secs(2)).is_ok() {
|
||||
out.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
std::thread::sleep(gap);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn percentile(v: &[f64], p: f64) -> Option<f64> {
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f64::total_cmp);
|
||||
let i = ((s.len() as f64 - 1.0) * p).round() as usize;
|
||||
Some(s[i])
|
||||
}
|
||||
|
||||
/// Upload until told to stop, counting bytes. Returns whether anything was
|
||||
/// accepted at all, so the caller can distinguish "slow" from "blocked".
|
||||
///
|
||||
/// The body is generated rather than read from anywhere, and is
|
||||
/// incompressible-enough zeroes; the sink discards it.
|
||||
fn upload_until(ag: &ureq::Agent, stop: &AtomicBool, sent: &AtomicU64) -> bool {
|
||||
let mut ok = false;
|
||||
let chunk = vec![0u8; CHUNK];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// One request per 8 MiB rather than one endless request: a long-lived
|
||||
// POST can be buffered by an intermediary, which would make the timing
|
||||
// a measure of the proxy rather than of the line.
|
||||
let body: Vec<u8> = chunk.repeat(8);
|
||||
let n = body.len() as u64;
|
||||
match ag
|
||||
.post(UPLOAD_URL)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.send(&body[..])
|
||||
{
|
||||
Ok(_) => {
|
||||
ok = true;
|
||||
sent.fetch_add(n, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
// One failure is a hiccup; the loop exits on the flag anyway.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Best-effort public-facing region, from Cloudflare's trace endpoint.
|
||||
///
|
||||
/// This answers "roughly where are you" without asking it, and it is coarse on
|
||||
/// purpose: the IATA code of the edge that served the request, which
|
||||
/// is a latency radius rather than a location. No IP address is retained and
|
||||
/// none goes into the summary line.
|
||||
pub fn region_hint() -> Option<String> {
|
||||
let body = agent()
|
||||
.get("https://speed.cloudflare.com/cdn-cgi/trace")
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
let mut loc = None;
|
||||
let mut colo = None;
|
||||
for line in body.lines() {
|
||||
match line.split_once('=') {
|
||||
Some(("loc", v)) => loc = Some(v.to_string()),
|
||||
Some(("colo", v)) => colo = Some(v.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match (loc, colo) {
|
||||
(Some(l), Some(c)) => Some(format!("{l}/{c}")),
|
||||
(Some(l), None) => Some(l),
|
||||
(None, c) => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a fixed prompt to the terminal and flush. Here rather than in `ask`
|
||||
/// because the network test narrates while it runs.
|
||||
pub fn tick(msg: &str) {
|
||||
print!("{msg}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
577
apps/nesdoctor/src/report.rs
Normal file
577
apps/nesdoctor/src/report.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! The verdict, the printed report, and the one line somebody pastes.
|
||||
//!
|
||||
//! Two audiences and they want different things. The person running this wants
|
||||
//! to know whether their machine is any good and what to fix. We want the
|
||||
//! distribution. The summary line is the only thing that crosses over, and it
|
||||
//! is built to be legible to both: a human can read it, and it parses.
|
||||
//!
|
||||
//! # What is not in the line
|
||||
//!
|
||||
//! No IP address, no hostname, no username, no game titles, no file paths, no
|
||||
//! machine identifier of any kind. A size *band* rather than a size, and an
|
||||
//! hour histogram rather than timestamps. The full JSON — which does contain
|
||||
//! titles and paths — stays on the local disk, and the person is told where.
|
||||
//!
|
||||
//! That is not politeness. A line that people are comfortable pasting in public
|
||||
//! is a line that gets pasted, and one that quietly carries their hostname gets
|
||||
//! screenshotted once and then never again.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ask::Answers;
|
||||
use crate::hostreq::{HostReport, State};
|
||||
use crate::net::NetReport;
|
||||
use crate::steam::{self, SteamReport};
|
||||
use crate::sys::SysInfo;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Full<'a> {
|
||||
pub nesdoctor: &'static str,
|
||||
pub sys: &'a SysInfo,
|
||||
pub host: &'a HostReport,
|
||||
pub net: &'a NetReport,
|
||||
pub steam: &'a SteamReport,
|
||||
pub answers: &'a Answers,
|
||||
pub verdict: Verdict,
|
||||
pub region_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// What this machine is, in one word, plus why.
|
||||
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Verdict {
|
||||
/// Passes every blocking host check, and the uplink is good enough.
|
||||
HostReady,
|
||||
/// Hardware and software are fine; the network is the problem.
|
||||
HostBlockedByNetwork,
|
||||
/// Every check passes, the uplink is clean — and the machine is a long way
|
||||
/// from the rest of the internet, so it can only usefully serve players
|
||||
/// near it. Which is the most interesting result this tool produces.
|
||||
HostReadyLocalOnly,
|
||||
/// Could host with setup work — nothing missing that cannot be installed.
|
||||
HostFixable,
|
||||
/// Cannot host. Usually the GPU vendor or the OS.
|
||||
ClientOnly,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Verdict::HostReady => "HOST-READY",
|
||||
Verdict::HostReadyLocalOnly => "HOST-READY-LOCAL",
|
||||
Verdict::HostBlockedByNetwork => "HOST-NET",
|
||||
Verdict::HostFixable => "HOST-FIXABLE",
|
||||
Verdict::ClientOnly => "CLIENT",
|
||||
Verdict::Unknown => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The uplink thresholds a host has to clear.
|
||||
///
|
||||
/// Both matter and the second matters more: 1080p60 needs 10–15 Mbps, and
|
||||
/// almost every fibre peer clears that, but added latency under load is what
|
||||
/// actually disqualifies a machine. See `net::grade`.
|
||||
const MIN_UP_MBPS: f64 = 15.0;
|
||||
const MAX_BLOAT_MS: f64 = 30.0;
|
||||
|
||||
/// Idle round trip to the nearest anycast edge, above which this machine can
|
||||
/// only serve players close to it.
|
||||
///
|
||||
/// This is a *floor* on what any player will see, not a measure of the machine.
|
||||
/// The network allowance is ~40 ms in total, so a host already spending 178 ms
|
||||
/// to reach Cloudflare's nearest point of presence cannot serve anyone who is
|
||||
/// not on roughly its own local networks — and no upgrade changes that, because
|
||||
/// it is distance.
|
||||
///
|
||||
/// Measured on the development connection 2026-09-02: 178 ms idle, served from
|
||||
/// Johannesburg. That machine passes every other check and still cannot host
|
||||
/// for a European player — which is the coverage argument seen from the other
|
||||
/// end: the places with no nearby edge are the places a local host is the only
|
||||
/// option anyone has.
|
||||
const FAR_RTT_MS: f64 = 60.0;
|
||||
|
||||
pub fn verdict(sys: &SysInfo, host: &HostReport, net: &NetReport) -> Verdict {
|
||||
// A machine that cannot host is a client, and that is a complete answer —
|
||||
// not a failure. Most respondents will land here and the wording matters.
|
||||
if !host.could_host {
|
||||
// Distinguish "wrong hardware" from "missing setup": an AMD or Intel
|
||||
// card with a render node and KVM is a fixable machine.
|
||||
let fixable = sys.os == "linux"
|
||||
&& host
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|c| c.blocking && c.state == State::Fail)
|
||||
.all(|c| c.id == "vkvideo");
|
||||
return if fixable {
|
||||
Verdict::HostFixable
|
||||
} else {
|
||||
Verdict::ClientOnly
|
||||
};
|
||||
}
|
||||
if host.unknowns > 0 {
|
||||
return Verdict::Unknown;
|
||||
}
|
||||
match (net.upstream_mbps, net.bloat_ms) {
|
||||
(Some(up), Some(bloat)) => {
|
||||
if up < MIN_UP_MBPS || bloat > MAX_BLOAT_MS {
|
||||
Verdict::HostBlockedByNetwork
|
||||
} else if net.idle_rtt_ms.is_some_and(|r| r > FAR_RTT_MS) {
|
||||
Verdict::HostReadyLocalOnly
|
||||
} else {
|
||||
Verdict::HostReady
|
||||
}
|
||||
}
|
||||
_ => Verdict::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line to paste. Pipe-separated fields, `k=v` inside, stable key order.
|
||||
/// Both renderers take the assembled report rather than seven arguments: the
|
||||
/// set of things they need is exactly [`Full`], and keeping them in step with
|
||||
/// it is the point.
|
||||
pub fn summary_line(f_: &Full) -> String {
|
||||
let (sys, host, net, steam, answers, verdict, region) = (
|
||||
f_.sys,
|
||||
f_.host,
|
||||
f_.net,
|
||||
f_.steam,
|
||||
f_.answers,
|
||||
f_.verdict,
|
||||
&f_.region_hint,
|
||||
);
|
||||
let mut f: Vec<String> = Vec::new();
|
||||
f.push(format!("nesdoctor {VERSION}"));
|
||||
f.push(format!("{}/{}", sys.os, sys.arch));
|
||||
|
||||
let gpu = sys
|
||||
.gpus
|
||||
.iter()
|
||||
.find(|g| g.render_node.is_some())
|
||||
.or_else(|| sys.gpus.first());
|
||||
f.push(format!(
|
||||
"gpu={}",
|
||||
gpu.map(|g| g.name.as_str()).unwrap_or("unknown")
|
||||
));
|
||||
|
||||
if let Some(r) = sys.ram_gib {
|
||||
f.push(format!("cpu={}t ram={r:.0}G", sys.cpu_threads));
|
||||
}
|
||||
|
||||
if sys.os == "linux" {
|
||||
let st = |id: &str| {
|
||||
host.checks
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| match c.state {
|
||||
State::Pass => "y",
|
||||
State::Fail => "n",
|
||||
State::Unknown => "?",
|
||||
})
|
||||
.unwrap_or("-")
|
||||
};
|
||||
f.push(format!(
|
||||
"kvm={} venc={} zfs={} boxfs={} io={}",
|
||||
st("kvm"),
|
||||
st("vkvideo"),
|
||||
st("content-store"),
|
||||
st("box-store"),
|
||||
st("cgroup-io")
|
||||
));
|
||||
}
|
||||
|
||||
match (net.upstream_mbps, net.bloat_ms, net.grade) {
|
||||
(Some(up), Some(b), Some(g)) => f.push(format!(
|
||||
"up={up:.0}Mbps rtt={}ms bloat=+{b:.0}ms grade={g}",
|
||||
net.idle_rtt_ms.map_or("?".into(), |r| format!("{r:.0}"))
|
||||
)),
|
||||
_ => f.push("net=unmeasured".into()),
|
||||
}
|
||||
|
||||
if let Some(d) = sys.disks.first() {
|
||||
f.push(format!("disk={:.0}G", d.free_gib));
|
||||
}
|
||||
if let (Some(h), Some(days)) = (sys.powered_hours_per_day, sys.powered_span_days) {
|
||||
f.push(format!("powered={h:.0}h/d over {days:.0}d"));
|
||||
}
|
||||
if let Some(r) = region {
|
||||
f.push(format!("edge={r}"));
|
||||
}
|
||||
|
||||
if steam.found && steam.titles > 0 {
|
||||
f.push(format!(
|
||||
"steam={} titles/{}",
|
||||
steam.titles,
|
||||
steam::size_band(steam.bytes_on_disk)
|
||||
));
|
||||
if let Some((s, e)) = steam.peak_window {
|
||||
f.push(format!("plays={s:02}-{e:02}h n={}", steam.launch_samples));
|
||||
}
|
||||
}
|
||||
|
||||
let a = |o: &Option<String>| o.clone().unwrap_or_else(|| "-".into());
|
||||
f.push(format!(
|
||||
"role={} share={} pays={}",
|
||||
a(&answers.role),
|
||||
a(&answers.share_for),
|
||||
a(&answers.pays_today)
|
||||
));
|
||||
if let Some(o) = &answers.other_linux {
|
||||
f.push(format!("otherlinux={o}"));
|
||||
}
|
||||
|
||||
f.push(verdict.tag().to_string());
|
||||
f.join(" | ")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ output ---
|
||||
|
||||
pub fn print_checks(host: &HostReport) {
|
||||
println!("\n\x1b[1mCan this machine run a Nestri box?\x1b[0m");
|
||||
for c in &host.checks {
|
||||
let (mark, colour) = match c.state {
|
||||
State::Pass => ("ok ", "32"),
|
||||
State::Fail => ("no ", "31"),
|
||||
State::Unknown => ("? ", "33"),
|
||||
};
|
||||
println!(" \x1b[{colour}m{mark}\x1b[0m {}", c.what);
|
||||
if !c.detail.is_empty() {
|
||||
for line in wrap(&c.detail, 68) {
|
||||
println!(" \x1b[2m{line}\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_verdict(v: Verdict, net: &NetReport) {
|
||||
println!();
|
||||
let (colour, headline, body) = match v {
|
||||
Verdict::HostReady => (
|
||||
"32",
|
||||
"This machine could host.",
|
||||
"Every hard requirement passes and the uplink is good enough. That is rarer \
|
||||
than it sounds — most machines fail on the encode extension or on queueing.",
|
||||
),
|
||||
Verdict::HostBlockedByNetwork => (
|
||||
"33",
|
||||
"Good machine, the network is in the way.",
|
||||
"The hardware and the software are fine. See the uplink figures above — if the \
|
||||
problem is added latency rather than throughput, it is a router setting and not \
|
||||
a line you need to upgrade.",
|
||||
),
|
||||
Verdict::HostReadyLocalOnly => (
|
||||
"32",
|
||||
"This machine could host — for players near you.",
|
||||
"Every requirement passes and your uplink queues cleanly. But the idle round trip to the nearest major network is already most of the latency budget, and that is distance rather than a fault: no upgrade shortens it. So this machine is a good host for people on your side of the world and cannot be one for anybody else. If you are somewhere without a cloud gaming edge, that is not a consolation prize — it is the only way anyone there gets a playable stream.",
|
||||
),
|
||||
Verdict::HostFixable => (
|
||||
"33",
|
||||
"This machine could host, with some setup.",
|
||||
"Nothing here is a hardware limit — what is missing can be installed.",
|
||||
),
|
||||
Verdict::ClientOnly => (
|
||||
"36",
|
||||
"This is a client, not a host.",
|
||||
"Which is a complete answer and not a failure: most machines are clients, and \
|
||||
the thing you would actually use Nestri for works fine from here.",
|
||||
),
|
||||
Verdict::Unknown => (
|
||||
"33",
|
||||
"Inconclusive.",
|
||||
"One or more checks could not be run rather than failing. The report says which; \
|
||||
an unknown is not a no.",
|
||||
),
|
||||
};
|
||||
println!("\x1b[1;{colour}m{headline}\x1b[0m");
|
||||
for line in wrap(body, 72) {
|
||||
println!("\x1b[2m{line}\x1b[0m");
|
||||
}
|
||||
if !net.note.is_empty() {
|
||||
println!();
|
||||
for line in wrap(&net.note, 72) {
|
||||
println!("\x1b[33m{line}\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap on whitespace. Twelve lines rather than a dependency, per `Cargo.toml`.
|
||||
fn wrap(s: &str, width: usize) -> Vec<String> {
|
||||
let mut out = vec![String::new()];
|
||||
for word in s.split_whitespace() {
|
||||
let cur = out.last_mut().unwrap();
|
||||
if !cur.is_empty() && cur.chars().count() + 1 + word.chars().count() > width {
|
||||
out.push(word.to_string());
|
||||
} else {
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
}
|
||||
}
|
||||
out.retain(|l| !l.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// Put the summary line on the clipboard, and say which tool did it.
|
||||
///
|
||||
/// Selecting a long line out of a terminal is fiddly and it is the last step
|
||||
/// before we learn anything, so it should not be work. Every one of these ships
|
||||
/// with the desktop it belongs to; where none is present we simply say so and
|
||||
/// the line is still on screen.
|
||||
pub fn to_clipboard(line: &str) -> Option<&'static str> {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const TOOLS: [(&str, &[&str]); 5] = [
|
||||
("wl-copy", &[]), // Wayland
|
||||
("xclip", &["-selection", "clipboard"]), // X11
|
||||
("xsel", &["--clipboard", "--input"]), // X11, the other one
|
||||
("pbcopy", &[]), // macOS
|
||||
("clip", &[]), // Windows
|
||||
];
|
||||
|
||||
for (tool, args) in TOOLS {
|
||||
let Ok(mut child) = Command::new(tool)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let wrote = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.is_some_and(|s| s.write_all(line.as_bytes()).is_ok());
|
||||
// Wait either way, so a failed tool is not left running.
|
||||
let ok = child.wait().map(|s| s.success()).unwrap_or(false);
|
||||
if wrote && ok {
|
||||
return Some(tool);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ submit ---
|
||||
|
||||
/// Percent-encode everything that is not unreserved. Small enough to write.
|
||||
fn enc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The URL that submits this run.
|
||||
///
|
||||
/// Query parameters rather than an opaque blob, deliberately. A base64 payload
|
||||
/// would be shorter and would let us send more, and it would also mean the
|
||||
/// person clicking cannot read what they are sending — which is the one thing
|
||||
/// this program has going for it. Readable parameters are self-documenting, and
|
||||
/// the length is nowhere near a browser limit.
|
||||
///
|
||||
/// Carries more than the clipboard line does, because it is not something
|
||||
/// anyone has to eyeball in a chat window: every check individually, the full
|
||||
/// latency triple, the 24-hour launch histogram, and the five largest titles.
|
||||
pub fn submit_url(base: &str, f_: &Full) -> String {
|
||||
let (sys, host, net, steam, answers, verdict, region) = (
|
||||
f_.sys,
|
||||
f_.host,
|
||||
f_.net,
|
||||
f_.steam,
|
||||
f_.answers,
|
||||
f_.verdict,
|
||||
&f_.region_hint,
|
||||
);
|
||||
let mut q: Vec<String> = Vec::new();
|
||||
let mut put = |k: &str, v: String| q.push(format!("{k}={}", enc(&v)));
|
||||
|
||||
put("v", VERSION.to_string());
|
||||
put("os", format!("{}/{}", sys.os, sys.arch));
|
||||
if let Some(rel) = &sys.release {
|
||||
put("rel", rel.clone());
|
||||
}
|
||||
if let Some(g) = sys
|
||||
.gpus
|
||||
.iter()
|
||||
.find(|g| g.render_node.is_some())
|
||||
.or_else(|| sys.gpus.first())
|
||||
{
|
||||
put("gpu", g.name.clone());
|
||||
}
|
||||
if sys.gpus.len() > 1 {
|
||||
put("gpus", sys.gpus.len().to_string());
|
||||
}
|
||||
put("cpu", sys.cpu_threads.to_string());
|
||||
if let Some(m) = &sys.cpu_model {
|
||||
put("cpumodel", m.clone());
|
||||
}
|
||||
if let Some(r) = sys.ram_gib {
|
||||
put("ram", format!("{r:.0}"));
|
||||
}
|
||||
|
||||
// Every check, individually — the aggregate verdict hides which single
|
||||
// requirement stops people, which is the thing worth knowing.
|
||||
for c in &host.checks {
|
||||
// Prefixed: the `gpu` check id would otherwise overwrite the GPU model
|
||||
// parameter, and last-writer-wins in a query string is a silent loss.
|
||||
put(
|
||||
&format!("ck_{}", c.id),
|
||||
match c.state {
|
||||
State::Pass => "y",
|
||||
State::Fail => "n",
|
||||
State::Unknown => "?",
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(u) = net.upstream_mbps {
|
||||
put("up", format!("{u:.1}"));
|
||||
}
|
||||
if let Some(v) = net.idle_rtt_ms {
|
||||
put("rtt", format!("{v:.0}"));
|
||||
}
|
||||
if let Some(v) = net.loaded_rtt_ms {
|
||||
put("rttload", format!("{v:.0}"));
|
||||
}
|
||||
if let Some(v) = net.loaded_rtt_p95_ms {
|
||||
put("rttp95", format!("{v:.0}"));
|
||||
}
|
||||
if let Some(v) = net.bloat_ms {
|
||||
put("bloat", format!("{v:.0}"));
|
||||
}
|
||||
if let Some(g) = net.grade {
|
||||
put("grade", g.to_string());
|
||||
}
|
||||
if let Some(r) = region {
|
||||
put("edge", r.clone());
|
||||
}
|
||||
|
||||
if let Some(d) = sys.disks.first() {
|
||||
put("disk", format!("{:.0}", d.free_gib));
|
||||
if let Some(fs) = &d.fs {
|
||||
put("diskfs", fs.clone());
|
||||
}
|
||||
}
|
||||
put("disks", sys.disks.len().to_string());
|
||||
if let (Some(h), Some(s)) = (sys.powered_hours_per_day, sys.powered_span_days) {
|
||||
put("powered", format!("{h:.1}"));
|
||||
put("span", format!("{s:.0}"));
|
||||
}
|
||||
|
||||
if steam.found {
|
||||
put("titles", steam.titles.to_string());
|
||||
put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk)));
|
||||
if steam.launch_samples > 0 {
|
||||
put(
|
||||
"hours",
|
||||
steam
|
||||
.launch_hours
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
put("n", steam.launch_samples.to_string());
|
||||
put("profiles", steam.profiles.to_string());
|
||||
if steam.launches_uninstalled > 0 {
|
||||
put("ngone", steam.launches_uninstalled.to_string());
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = steam.peak_window {
|
||||
put("peak", format!("{a}-{b}"));
|
||||
}
|
||||
if !steam.largest.is_empty() {
|
||||
// Whether the title distribution has a head decides whether a depot
|
||||
// cache is worth building at all, and it cannot be seen from counts.
|
||||
put(
|
||||
"top",
|
||||
steam
|
||||
.largest
|
||||
.iter()
|
||||
.map(|(n, _)| n.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("~"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in [
|
||||
("want", &answers.want),
|
||||
("role", &answers.role),
|
||||
("share", &answers.share_for),
|
||||
("pays", &answers.pays_today),
|
||||
("otherlinux", &answers.other_linux),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
put(k, v.clone());
|
||||
}
|
||||
}
|
||||
put("verdict", verdict.tag().to_string());
|
||||
|
||||
format!("{}/?{}", base.trim_end_matches('/'), q.join("&"))
|
||||
}
|
||||
|
||||
/// Plain English list of what the submit URL contains, printed before it opens.
|
||||
///
|
||||
/// The URL is readable, but it is also 800 characters long and nobody reads
|
||||
/// 800 characters. This is the honest summary of it.
|
||||
pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static str> {
|
||||
let mut v = vec![
|
||||
"this machine's OS, CPU, RAM and GPU model",
|
||||
"which host requirements passed and which did not",
|
||||
"the network figures you just saw",
|
||||
"free disk space, and how long this machine tends to stay on",
|
||||
];
|
||||
if steam.found && steam.titles > 0 {
|
||||
v.push("how many games are installed, their total size, and your five largest");
|
||||
if steam.launch_samples > 0 {
|
||||
v.push("the hour-of-day histogram above — hours, never dates");
|
||||
}
|
||||
}
|
||||
if answers.want.is_some()
|
||||
|| answers.role.is_some()
|
||||
|| answers.share_for.is_some()
|
||||
|| answers.pays_today.is_some()
|
||||
{
|
||||
v.push("your answers to the questions");
|
||||
}
|
||||
v.push("no hostname, no IP address, no username, no file paths");
|
||||
v
|
||||
}
|
||||
|
||||
/// Hand a URL to whatever the desktop uses to open links.
|
||||
pub fn open_in_browser(url: &str) -> bool {
|
||||
use std::process::{Command, Stdio};
|
||||
let attempts: [(&str, &[&str]); 4] = [
|
||||
("xdg-open", &[]),
|
||||
("open", &[]), // macOS
|
||||
("cmd", &["/C", "start", ""]), // Windows
|
||||
("wslview", &[]), // WSL, where xdg-open is often absent
|
||||
];
|
||||
for (cmd, args) in attempts {
|
||||
if Command::new(cmd)
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
368
apps/nesdoctor/src/steam.rs
Normal file
368
apps/nesdoctor/src/steam.rs
Normal file
@@ -0,0 +1,368 @@
|
||||
//! What Steam already knows, read locally and only with permission.
|
||||
//!
|
||||
//! Three things we would otherwise have to ask about are answerable from files
|
||||
//! on disk, and all three are ones people answer badly when asked:
|
||||
//!
|
||||
//! | question | what we read |
|
||||
//! |---|---|
|
||||
//! | *what do you play?* | `appmanifest_*.acf` — title and size on disk |
|
||||
//! | *how big is a library?* | the sum of those sizes, which is the content store's cost |
|
||||
//! | *when do you play?* | `localconfig.vdf` — `LastPlayed` per title, as an hour-of-day histogram |
|
||||
//!
|
||||
//! The third is the interesting one. Steam keeps one `LastPlayed` timestamp per
|
||||
//! title, so a library of eighty games is **eighty samples of what hour this
|
||||
//! person launches a game at** — a real distribution, taken without asking, and
|
||||
//! the thing a demand trough is made of. It is biased toward whatever
|
||||
//! they played most recently and it is not a schedule; it is a sample, and it is
|
||||
//! reported as one.
|
||||
//!
|
||||
//! # This is somebody's private library
|
||||
//!
|
||||
//! Nothing here runs without an explicit yes, nothing leaves the machine, and
|
||||
//! the summary line carries **counts and hours, never titles**. The full JSON
|
||||
//! stays in a local file the caller is told the path of. Reading a game library
|
||||
//! is not a neutral act and the code is arranged so that a reader can confirm
|
||||
//! that in one pass.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::vdf;
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct SteamReport {
|
||||
pub found: bool,
|
||||
pub roots: Vec<String>,
|
||||
pub titles: usize,
|
||||
pub bytes_on_disk: u64,
|
||||
/// The five largest, by size on disk. Included because library *shape* is
|
||||
/// what decides whether a depot cache has a head to cache.
|
||||
pub largest: Vec<(String, u64)>,
|
||||
/// Count of `LastPlayed` timestamps falling in each local hour, 0–23.
|
||||
pub launch_hours: [u32; 24],
|
||||
pub launch_samples: usize,
|
||||
/// How many Steam user profiles were found on this machine. More than one
|
||||
/// means more than one person may use it, and the histogram is taken from
|
||||
/// the busiest profile rather than summed across strangers.
|
||||
pub profiles: usize,
|
||||
/// Launch records whose title is not installed any more. Kept in the
|
||||
/// histogram on purpose — a game someone uninstalled is still a real
|
||||
/// record of when they play — and reported so `n` cannot be mistaken for
|
||||
/// the installed-title count.
|
||||
pub launches_uninstalled: usize,
|
||||
/// Hours covering half of all launches, contiguous and wrapping — the
|
||||
/// "evening peak" if there is one.
|
||||
pub peak_window: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Proton builds, runtimes and redistributables are installed like games and
|
||||
/// are not games.
|
||||
///
|
||||
/// Measured 2026-09-02: on a machine with one real title, five of the eight
|
||||
/// entries were runtimes — so counting them inflates the title count by 5x and
|
||||
/// corrupts the library-shape question this is here to answer. Matching on the
|
||||
/// name is imperfect and is the honest trade: a title genuinely called
|
||||
/// "Proton …" would be dropped, and no real title is.
|
||||
fn is_runtime(title: &str) -> bool {
|
||||
const PREFIXES: [&str; 5] = [
|
||||
"Proton",
|
||||
"Steam Linux Runtime",
|
||||
"Steamworks Common Redistributables",
|
||||
"Steam Deck",
|
||||
"SteamVR",
|
||||
];
|
||||
PREFIXES.iter().any(|p| title.starts_with(p))
|
||||
}
|
||||
|
||||
/// Where Steam might be. Checked in order; all hits are used, because a library
|
||||
/// is routinely split across drives.
|
||||
fn candidate_roots() -> Vec<PathBuf> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from);
|
||||
let mut v = Vec::new();
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".steam/steam"));
|
||||
v.push(h.join(".local/share/Steam"));
|
||||
// Flatpak keeps its own home.
|
||||
v.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"));
|
||||
v.push(h.join("Library/Application Support/Steam")); // macOS
|
||||
}
|
||||
v.push(PathBuf::from(r"C:\Program Files (x86)\Steam"));
|
||||
v.push(PathBuf::from("/usr/lib/steam"));
|
||||
|
||||
// Canonicalise before deduplicating. `~/.steam/steam` is conventionally a
|
||||
// symlink to `~/.local/share/Steam`, so both candidates hit and every
|
||||
// profile is found twice -- measured on the development machine, which
|
||||
// reported four Steam profiles for two real ones and would have claimed a
|
||||
// shared machine where there is not one.
|
||||
let mut out: Vec<PathBuf> = v
|
||||
.into_iter()
|
||||
.filter(|p| p.join("steamapps").is_dir())
|
||||
.map(|p| fs::canonicalize(&p).unwrap_or(p))
|
||||
.collect();
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// True when there is anything to ask about. Called *before* consent so the
|
||||
/// question is not asked of someone with no Steam install.
|
||||
pub fn present() -> bool {
|
||||
!candidate_roots().is_empty()
|
||||
}
|
||||
|
||||
pub fn read() -> SteamReport {
|
||||
let roots = candidate_roots();
|
||||
if roots.is_empty() {
|
||||
return SteamReport::default();
|
||||
}
|
||||
|
||||
let mut r = SteamReport {
|
||||
found: true,
|
||||
roots: roots.iter().map(|p| p.display().to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Library folders can live on other drives; `libraryfolders.vdf` lists
|
||||
// them, and skipping it undercounts a split library badly.
|
||||
let mut app_dirs: Vec<PathBuf> = roots.iter().map(|p| p.join("steamapps")).collect();
|
||||
for root in &roots {
|
||||
let lf = root.join("steamapps/libraryfolders.vdf");
|
||||
if let Ok(txt) = fs::read_to_string(&lf) {
|
||||
let doc = vdf::parse(&txt);
|
||||
if let Some(folders) = doc.get(&["libraryfolders"]).and_then(vdf::Value::as_node) {
|
||||
for entry in folders.values() {
|
||||
if let Some(p) = entry.get(&["path"]).and_then(vdf::Value::as_str) {
|
||||
let d = PathBuf::from(p).join("steamapps");
|
||||
if d.is_dir() {
|
||||
app_dirs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app_dirs.sort();
|
||||
app_dirs.dedup();
|
||||
|
||||
let mut by_size: Vec<(String, u64)> = Vec::new();
|
||||
// appid -> (title, is_runtime). Needed by the launch histogram below, which
|
||||
// sees appids and nothing else.
|
||||
let mut known: BTreeMap<String, (String, bool)> = BTreeMap::new();
|
||||
for dir in &app_dirs {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !(name.starts_with("appmanifest_") && name.ends_with(".acf")) {
|
||||
continue;
|
||||
}
|
||||
let Ok(txt) = fs::read_to_string(e.path()) else {
|
||||
continue;
|
||||
};
|
||||
let doc = vdf::parse(&txt);
|
||||
let title = doc
|
||||
.get(&["AppState", "name"])
|
||||
.and_then(vdf::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
if let Some(id) = doc
|
||||
.get(&["AppState", "appid"])
|
||||
.and_then(vdf::Value::as_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
known.insert(id, (title.clone(), is_runtime(&title)));
|
||||
}
|
||||
let size = doc
|
||||
.get(&["AppState", "SizeOnDisk"])
|
||||
.and_then(vdf::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if is_runtime(&title) {
|
||||
continue;
|
||||
}
|
||||
by_size.push((title, size));
|
||||
}
|
||||
}
|
||||
// A split library can list the same appid twice; dedupe by title.
|
||||
by_size.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
by_size.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
r.titles = by_size.len();
|
||||
r.bytes_on_disk = by_size.iter().map(|(_, s)| s).sum();
|
||||
by_size.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
|
||||
r.largest = by_size.into_iter().take(5).collect();
|
||||
|
||||
// --- when do they play -----------------------------------------------
|
||||
//
|
||||
// `localconfig.vdf` keeps a `LastPlayed` per app, which makes a library of
|
||||
// eighty games eighty samples of what hour this person starts playing.
|
||||
// Two things have to be handled or the number is wrong:
|
||||
//
|
||||
// **Runtimes are not launches.** Proton and the Steam Linux Runtimes carry
|
||||
// `LastPlayed` like any app, and Steam starts them itself, at whatever hour
|
||||
// it happens to update them. They are filtered here by joining the appid
|
||||
// against the installed-title names — the same filter the title count uses,
|
||||
// so the two cannot disagree about what a game is.
|
||||
//
|
||||
// **Profiles are not one person.** A shared machine has several, and
|
||||
// summing them produces a histogram of nobody. The busiest profile is used
|
||||
// and the count is reported, so a two-profile machine is visible as one.
|
||||
let mut per_profile: Vec<([u32; 24], usize, usize)> = Vec::new();
|
||||
for root in &roots {
|
||||
let Ok(users) = fs::read_dir(root.join("userdata")) else {
|
||||
continue;
|
||||
};
|
||||
for u in users.flatten() {
|
||||
let cfg = u.path().join("config/localconfig.vdf");
|
||||
let Ok(txt) = fs::read_to_string(&cfg) else {
|
||||
continue;
|
||||
};
|
||||
let doc = vdf::parse(&txt);
|
||||
let Some(apps) = doc
|
||||
.get(&["UserLocalConfigStore", "Software", "Valve", "Steam", "apps"])
|
||||
.and_then(vdf::Value::as_node)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut hours = [0u32; 24];
|
||||
let mut n = 0usize;
|
||||
let mut gone = 0usize;
|
||||
for (appid, app) in apps {
|
||||
let Some(ts) = app.get(&["LastPlayed"]).and_then(vdf::Value::as_u64) else {
|
||||
continue;
|
||||
};
|
||||
if ts == 0 {
|
||||
continue;
|
||||
}
|
||||
match known.get(appid) {
|
||||
// A tool Steam launched, not a person playing.
|
||||
Some((_, true)) => continue,
|
||||
Some((_, false)) => {}
|
||||
// Not installed now. A real launch by a real person, and
|
||||
// counted — but counted separately so `n` is never read as
|
||||
// the installed-title count.
|
||||
None => gone += 1,
|
||||
}
|
||||
if let Some(h) = local_hour(ts) {
|
||||
hours[h as usize] += 1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
per_profile.push((hours, n, gone));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.profiles = per_profile.len();
|
||||
if let Some((hours, n, gone)) = per_profile.into_iter().max_by_key(|(_, n, _)| *n) {
|
||||
r.launch_hours = hours;
|
||||
r.launch_samples = n;
|
||||
r.launches_uninstalled = gone;
|
||||
}
|
||||
|
||||
r.peak_window = peak_window(&r.launch_hours, r.launch_samples);
|
||||
r
|
||||
}
|
||||
|
||||
/// Hour of day, in the machine's local time, for a Unix timestamp.
|
||||
///
|
||||
/// Done with the offset the OS reports rather than a timezone crate: we need
|
||||
/// the hour a person launched a game in their own reckoning, and one integer
|
||||
/// offset is enough for that. DST at the boundary shifts a sample by an hour,
|
||||
/// which is inside the resolution this is reported at.
|
||||
fn local_hour(unix: u64) -> Option<u32> {
|
||||
let offset = utc_offset_seconds()?;
|
||||
let local = unix as i64 + offset;
|
||||
Some((local.rem_euclid(86_400) / 3600) as u32)
|
||||
}
|
||||
|
||||
fn utc_offset_seconds() -> Option<i64> {
|
||||
// `date +%z` gives `+0200`. Present on every unix; PowerShell for Windows.
|
||||
let z = crate::sys::sh("date", &["+%z"]).or_else(|| {
|
||||
crate::sys::ps("(Get-TimeZone).BaseUtcOffset.TotalSeconds")
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.map(|s| format!("{:+05}", (s as i64 / 3600) * 100))
|
||||
})?;
|
||||
let z = z.trim();
|
||||
let sign = if z.starts_with('-') { -1 } else { 1 };
|
||||
let digits: String = z.chars().filter(char::is_ascii_digit).collect();
|
||||
if digits.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let h: i64 = digits[0..2].parse().ok()?;
|
||||
let m: i64 = digits[2..4].parse().ok()?;
|
||||
Some(sign * (h * 3600 + m * 60))
|
||||
}
|
||||
|
||||
/// The shortest contiguous, wrapping run of hours holding at least half the
|
||||
/// launches. That is the honest form of "when do you play": if it is four hours
|
||||
/// wide there is an evening peak, and if it takes fourteen there is not.
|
||||
fn peak_window(hours: &[u32; 24], total: usize) -> Option<(u32, u32)> {
|
||||
if total < 8 {
|
||||
return None; // too few samples to claim a shape
|
||||
}
|
||||
let half = (total as f64 / 2.0).ceil() as u32;
|
||||
let mut best: Option<(u32, u32, u32)> = None; // width, start, end
|
||||
for start in 0..24u32 {
|
||||
let mut sum = 0;
|
||||
for w in 1..=24u32 {
|
||||
sum += hours[((start + w - 1) % 24) as usize];
|
||||
if sum >= half {
|
||||
let cand = (w, start, (start + w - 1) % 24);
|
||||
if best.is_none_or(|b| w < b.0) {
|
||||
best = Some(cand);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, s, e)| (s, e))
|
||||
}
|
||||
|
||||
/// GiB, for display.
|
||||
pub fn gib(bytes: u64) -> f64 {
|
||||
bytes as f64 / 1_073_741_824.0
|
||||
}
|
||||
|
||||
/// A one-line sparkline of the launch-hour histogram.
|
||||
///
|
||||
/// Worth the twenty lines: it is the part of the output people screenshot, and
|
||||
/// it is the only place someone sees their own play schedule as a shape.
|
||||
pub fn sparkline(hours: &[u32; 24]) -> String {
|
||||
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
let max = *hours.iter().max().unwrap_or(&0);
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
hours
|
||||
.iter()
|
||||
.map(|&h| {
|
||||
if h == 0 {
|
||||
' '
|
||||
} else {
|
||||
BARS[((h as f64 / max as f64) * 7.0).round() as usize]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Titles are never put in the paste line, so this is what goes instead:
|
||||
/// a coarse size band, which is all a content-store estimate needs.
|
||||
pub fn size_band(bytes: u64) -> &'static str {
|
||||
match gib(bytes) {
|
||||
g if g < 100.0 => "<100G",
|
||||
g if g < 500.0 => "100-500G",
|
||||
g if g < 1500.0 => "0.5-1.5T",
|
||||
_ => ">1.5T",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn debug_map(_m: &BTreeMap<String, vdf::Value>) {}
|
||||
545
apps/nesdoctor/src/sys.rs
Normal file
545
apps/nesdoctor/src/sys.rs
Normal file
@@ -0,0 +1,545 @@
|
||||
//! What the machine is: OS, CPU, memory, GPU, disk, and how long it stays on.
|
||||
//!
|
||||
//! Everything here is read from files or from a command that ships with the OS.
|
||||
//! No crate is used to describe hardware, because a wrong answer from a
|
||||
//! dependency is indistinguishable from a wrong answer from us, and this output
|
||||
//! is what a host-capacity decision would rest on: hosts are
|
||||
//! customer-supplied and heterogeneous, so an unlabelled capacity number is a
|
||||
//! wrong one.
|
||||
//!
|
||||
//! Every probe degrades to `None` rather than failing the run. A missing
|
||||
//! `lspci` costs one field.
|
||||
|
||||
// Every probe in this module is a stack of `#[cfg]`-gated `return`s, one per
|
||||
// platform, so that exactly one compiles. The trailing `return` in each arm is
|
||||
// load-bearing -- dropping it makes the arms fall through to each other and the
|
||||
// function stops compiling on some targets -- so clippy's advice is wrong here
|
||||
// specifically, and is not suppressed anywhere else in the crate.
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SysInfo {
|
||||
pub os: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub release: Option<String>,
|
||||
pub kernel: Option<String>,
|
||||
pub cpu_model: Option<String>,
|
||||
pub cpu_threads: usize,
|
||||
pub ram_gib: Option<f64>,
|
||||
pub gpus: Vec<Gpu>,
|
||||
/// Mounts with usable free space, largest first.
|
||||
pub disks: Vec<Disk>,
|
||||
pub uptime_hours: Option<f64>,
|
||||
/// Mean hours per day the machine was powered, from boot history. See
|
||||
/// [`powered`]. `None` where the history is not readable.
|
||||
pub powered_hours_per_day: Option<f64>,
|
||||
/// Days the boot history spans, so the reader can judge the above.
|
||||
pub powered_span_days: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: Option<String>,
|
||||
/// The DRM render node, where one exists. Linux only, and a hard
|
||||
/// requirement in `contracts/host-requirements.md`: a card without one
|
||||
/// cannot host, however good it is.
|
||||
pub render_node: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Disk {
|
||||
pub mount: String,
|
||||
pub fs: Option<String>,
|
||||
/// The backing device. Kept because btrfs and ZFS present many mount
|
||||
/// points on one device: without this, three subvolumes of one 91 GiB disk
|
||||
/// read as 273 GiB of capacity, and the two-stores check (which wants
|
||||
/// *separate devices*) cannot be answered at all.
|
||||
pub source: Option<String>,
|
||||
pub free_gib: f64,
|
||||
}
|
||||
|
||||
pub fn probe() -> SysInfo {
|
||||
let (powered_hours_per_day, powered_span_days) = powered();
|
||||
SysInfo {
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
release: release(),
|
||||
kernel: kernel(),
|
||||
cpu_model: cpu_model(),
|
||||
cpu_threads: std::thread::available_parallelism().map_or(0, |n| n.get()),
|
||||
ram_gib: ram_gib(),
|
||||
gpus: gpus(),
|
||||
disks: disks(),
|
||||
uptime_hours: uptime_hours(),
|
||||
powered_hours_per_day,
|
||||
powered_span_days,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- identity ---
|
||||
|
||||
fn release() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return kv_line(&fs::read_to_string("/etc/os-release").ok()?, "PRETTY_NAME");
|
||||
#[cfg(windows)]
|
||||
return ps("(Get-CimInstance Win32_OperatingSystem).Caption");
|
||||
#[cfg(target_os = "macos")]
|
||||
return sh("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"));
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
fn kernel() -> Option<String> {
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
sh("uname", &["-r"])
|
||||
}
|
||||
|
||||
fn cpu_model() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return fs::read_to_string("/proc/cpuinfo")
|
||||
.ok()?
|
||||
.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_string());
|
||||
#[cfg(windows)]
|
||||
return ps("(Get-CimInstance Win32_Processor).Name");
|
||||
#[cfg(target_os = "macos")]
|
||||
return sh("sysctl", &["-n", "machdep.cpu.brand_string"]);
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
fn ram_gib() -> Option<f64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let txt = fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb: f64 = txt
|
||||
.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()?;
|
||||
return Some(kb / 1048576.0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
return Some(
|
||||
ps("(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory")?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
/ 1073741824.0,
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
return Some(
|
||||
sh("sysctl", &["-n", "hw.memsize"])?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
/ 1073741824.0,
|
||||
);
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- gpu ---
|
||||
|
||||
/// PCI vendor ids as they appear in `/sys/.../vendor`.
|
||||
fn vendor_name(id: &str) -> Option<&'static str> {
|
||||
match id.trim().trim_start_matches("0x") {
|
||||
"1002" => Some("AMD"),
|
||||
"8086" => Some("Intel"),
|
||||
"10de" => Some("NVIDIA"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gpus() -> Vec<Gpu> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux_gpus();
|
||||
#[cfg(windows)]
|
||||
return ps("Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }")
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| {
|
||||
let up = l.to_uppercase();
|
||||
Gpu {
|
||||
name: l.to_string(),
|
||||
vendor: ["AMD", "NVIDIA", "INTEL"]
|
||||
.into_iter()
|
||||
.find(|v| up.contains(v))
|
||||
.map(str::to_string),
|
||||
render_node: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
/// Walk `/sys/class/drm` for cards and pair each with its render node.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_gpus() -> Vec<Gpu> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
|
||||
return out;
|
||||
};
|
||||
let all: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
||||
|
||||
let mut cards: Vec<&PathBuf> = all
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("card") && !n.contains('-'))
|
||||
})
|
||||
.collect();
|
||||
cards.sort();
|
||||
|
||||
let lspci = sh("lspci", &["-mm"]).unwrap_or_default();
|
||||
|
||||
for card in cards {
|
||||
let dev = card.join("device");
|
||||
let real = fs::canonicalize(&dev).ok();
|
||||
let vendor = fs::read_to_string(dev.join("vendor"))
|
||||
.ok()
|
||||
.and_then(|v| vendor_name(&v))
|
||||
.map(str::to_string);
|
||||
|
||||
// The PCI slot is the symlink target's basename; lspci -mm keys on the
|
||||
// bus:device.function part of it.
|
||||
let slot = real
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.unwrap_or_default();
|
||||
let bdf = slot
|
||||
.split_once(':')
|
||||
.map_or(slot.clone(), |(_, r)| r.to_string());
|
||||
|
||||
let name = lspci
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&bdf))
|
||||
// lspci -mm quotes each field; index 5 is the device name.
|
||||
.and_then(|l| l.split('"').nth(5).map(str::to_string))
|
||||
.or_else(|| {
|
||||
fs::read_to_string(dev.join("device")).ok().map(|d| {
|
||||
format!(
|
||||
"{} device {}",
|
||||
vendor.clone().unwrap_or_else(|| "unknown".into()),
|
||||
d.trim()
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "unknown GPU".into());
|
||||
|
||||
let render_node = all
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("renderD"))
|
||||
})
|
||||
.find(|p| fs::canonicalize(p.join("device")).ok() == real)
|
||||
.and_then(|p| {
|
||||
p.file_name()
|
||||
.map(|n| format!("/dev/dri/{}", n.to_string_lossy()))
|
||||
});
|
||||
|
||||
// 0041 requires a *recorded* gpu_model per host, so prefer a name that
|
||||
// identifies the part. lspci gives the codename alone ("Barcelo"),
|
||||
// which is thin on its own.
|
||||
let name = match &vendor {
|
||||
Some(v) if !name.to_uppercase().contains(&v.to_uppercase()) => format!("{v} {name}"),
|
||||
_ => name,
|
||||
};
|
||||
out.push(Gpu {
|
||||
name,
|
||||
vendor,
|
||||
render_node,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- disk ---
|
||||
|
||||
fn disks() -> Vec<Disk> {
|
||||
let mut out = Vec::new();
|
||||
#[cfg(unix)]
|
||||
if let Some(txt) = sh("df", &["-Pk"]) {
|
||||
// -P for POSIX output and -k for a unit that does not move under
|
||||
// locale. Both matter, because this is parsed.
|
||||
for line in txt.lines().skip(1) {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let Ok(avail_kb) = f[3].parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let source = f[0].to_string();
|
||||
let mount = f[5..].join(" ");
|
||||
// Pseudo-filesystems are noise, and tmpfs free space is RAM.
|
||||
if ["/dev", "/sys", "/proc", "/run", "/boot", "/snap"]
|
||||
.iter()
|
||||
.any(|p| mount.starts_with(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(Disk {
|
||||
fs: fs_type(&mount),
|
||||
mount,
|
||||
source: Some(source),
|
||||
free_gib: avail_kb / 1048576.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(txt) =
|
||||
ps("Get-PSDrive -PSProvider FileSystem | ForEach-Object { \"$($_.Name)|$($_.Free)\" }")
|
||||
{
|
||||
for line in txt.lines() {
|
||||
if let Some((name, free)) = line.split_once('|') {
|
||||
if let Ok(b) = free.trim().parse::<f64>() {
|
||||
out.push(Disk {
|
||||
mount: format!("{}:", name.trim()),
|
||||
fs: None,
|
||||
source: None,
|
||||
free_gib: b / 1073741824.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.free_gib.total_cmp(&a.free_gib));
|
||||
out.dedup_by(|a, b| a.mount == b.mount);
|
||||
// One entry per backing device. Measured 2026-09-02: this laptop reported
|
||||
// /, /home and /srv at 91 GiB each — three btrfs subvolumes of one device,
|
||||
// counted three times.
|
||||
out.dedup_by(|a, b| a.source.is_some() && a.source == b.source);
|
||||
out
|
||||
}
|
||||
|
||||
/// The physical block devices behind a `df` source string.
|
||||
///
|
||||
/// A source string is not a device. `/dev/nvme0n1p2` and `/dev/nvme0n1p3` are
|
||||
/// two strings and one SSD, sharing one queue — so comparing the strings says
|
||||
/// "separate devices" about a topology with no I/O isolation whatever, which is
|
||||
/// the entire reason the two-stores requirement exists. LVM is worse: two
|
||||
/// logical volumes on one physical disk look completely unrelated.
|
||||
///
|
||||
/// So: a partition resolves to its parent disk through sysfs, a device-mapper
|
||||
/// or MD device resolves to everything in its `slaves/` directory, recursively,
|
||||
/// and anything unrecognised resolves to itself. Two mounts share hardware when
|
||||
/// the returned sets intersect.
|
||||
pub fn physical_devices(source: &str) -> Vec<String> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return vec![source.to_string()];
|
||||
}
|
||||
let name = source.rsplit('/').next().unwrap_or(source);
|
||||
let mut out = Vec::new();
|
||||
resolve_device(name, &mut out, 0);
|
||||
if out.is_empty() {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn resolve_device(name: &str, out: &mut Vec<String>, depth: u8) {
|
||||
// Stacked device mapper (LUKS over LVM over MD) nests, and a cycle would
|
||||
// otherwise be a hang in a diagnostic tool.
|
||||
if depth > 6 || name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base = format!("/sys/class/block/{name}");
|
||||
if !Path::new(&base).exists() {
|
||||
out.push(name.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
// A partition: its sysfs parent directory is the whole disk.
|
||||
if Path::new(&format!("{base}/partition")).exists()
|
||||
&& let Some(disk) = fs::canonicalize(&base)
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
.and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
{
|
||||
resolve_device(&disk, out, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Device mapper, MD or anything else built on other devices.
|
||||
if let Ok(slaves) = fs::read_dir(format!("{base}/slaves")) {
|
||||
let mut any = false;
|
||||
for s in slaves.flatten() {
|
||||
any = true;
|
||||
resolve_device(&s.file_name().to_string_lossy(), out, depth + 1);
|
||||
}
|
||||
if any {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(name.to_string());
|
||||
}
|
||||
|
||||
/// Filesystem type for a mount point.
|
||||
///
|
||||
/// `hostreq` needs this in both directions: ZFS is *required* for the content
|
||||
/// store and *disqualifying* for the box store, because it ignores `O_DIRECT`
|
||||
/// ignores `O_DIRECT`.
|
||||
pub fn fs_type(mount: &str) -> Option<String> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
}
|
||||
// Last match wins: a later mount shadows an earlier one on the same point.
|
||||
fs::read_to_string("/proc/mounts")
|
||||
.ok()?
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut f = l.split_whitespace();
|
||||
let _src = f.next()?;
|
||||
let mnt = f.next()?;
|
||||
let ty = f.next()?;
|
||||
(mnt == mount).then(|| ty.to_string())
|
||||
})
|
||||
.next_back()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ powered ---
|
||||
|
||||
fn uptime_hours() -> Option<f64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return Some(
|
||||
fs::read_to_string("/proc/uptime")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
/ 3600.0,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
return ps(
|
||||
"[int]((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalSeconds",
|
||||
)?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|s| s / 3600.0);
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
return None;
|
||||
}
|
||||
|
||||
/// Mean hours per day the machine was powered, and the span that covers.
|
||||
///
|
||||
/// This exists so no question has to ask *"how many hours is this machine
|
||||
/// on?"* — which is exactly the kind of question nobody can answer about
|
||||
/// themselves, so it should never be asked.
|
||||
///
|
||||
/// Method: `journalctl --list-boots -o json` gives a `first_entry` and
|
||||
/// `last_entry` microsecond timestamp per boot. Summing `last − first` gives
|
||||
/// time powered; `max(last) − min(first)` gives the wall-clock span. The ratio
|
||||
/// is the answer, and it needs no date parsing at all — only integers.
|
||||
///
|
||||
/// It is a **coarse** instrument and is reported as one: it measures powered,
|
||||
/// not idle, and a machine that suspends looks powered-off. It answers "always
|
||||
/// on" versus "a few hours in the evening", which is the only resolution the
|
||||
/// availability question needs at this stage.
|
||||
pub fn powered() -> (Option<f64>, Option<f64>) {
|
||||
let Some(txt) = sh("journalctl", &["--list-boots", "-o", "json", "--no-pager"]) else {
|
||||
return (None, None);
|
||||
};
|
||||
let mut up_us: u128 = 0;
|
||||
let (mut lo, mut hi) = (u128::MAX, 0u128);
|
||||
let mut boots = 0usize;
|
||||
|
||||
// Deliberately not a JSON parse: the shape is flat and stable, and pulling
|
||||
// the whole document through serde_json to read two integers per record
|
||||
// buys nothing.
|
||||
for first in txt.split("\"first_entry\":").skip(1) {
|
||||
let Some(a) = read_int(first) else { continue };
|
||||
let Some(rest) = first.split_once("\"last_entry\":") else {
|
||||
continue;
|
||||
};
|
||||
let Some(b) = read_int(rest.1) else { continue };
|
||||
if b <= a {
|
||||
continue;
|
||||
}
|
||||
up_us += b - a;
|
||||
lo = lo.min(a);
|
||||
hi = hi.max(b);
|
||||
boots += 1;
|
||||
}
|
||||
if boots < 2 || hi <= lo {
|
||||
return (None, None);
|
||||
}
|
||||
let span_days = (hi - lo) as f64 / 86_400_000_000.0;
|
||||
// Under three days this is one or two boots and says nothing about a
|
||||
// habit. Reporting it anyway invites someone to read "13 h/day" off two
|
||||
// days of history, so report the span with no rate instead.
|
||||
if span_days < 3.0 {
|
||||
return (None, Some(span_days));
|
||||
}
|
||||
let up_hours = up_us as f64 / 3_600_000_000.0;
|
||||
(Some(up_hours / span_days), Some(span_days))
|
||||
}
|
||||
|
||||
fn read_int(s: &str) -> Option<u128> {
|
||||
let s = s.trim_start().trim_start_matches('"');
|
||||
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- shell ---
|
||||
|
||||
/// Run a command, return trimmed stdout, `None` on any failure.
|
||||
pub fn sh(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(cmd).args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// PowerShell, for the Windows probes. `-NoProfile` so a user's profile script
|
||||
/// cannot change what we read.
|
||||
#[allow(dead_code)]
|
||||
pub fn ps(script: &str) -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
sh(
|
||||
"powershell",
|
||||
&["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn exists(p: &str) -> bool {
|
||||
Path::new(p).exists()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn kv_line(txt: &str, key: &str) -> Option<String> {
|
||||
txt.lines()
|
||||
.find(|l| l.starts_with(&format!("{key}=")))
|
||||
.and_then(|l| l.split_once('='))
|
||||
.map(|(_, v)| v.trim().trim_matches('"').to_string())
|
||||
}
|
||||
227
apps/nesdoctor/src/vdf.rs
Normal file
227
apps/nesdoctor/src/vdf.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
//! A minimal reader for Valve's KeyValues text format (VDF).
|
||||
//!
|
||||
//! Steam writes several files we want in this format, and all of them are the
|
||||
//! simple textual dialect: `"key" "value"` for leaves, `"key" { ... }` for
|
||||
//! nodes, `//` to end of line for comments. There is a binary dialect and there
|
||||
//! are `#include` and conditional (`[$WIN32]`) forms; **none of them appear in
|
||||
//! the three files this crate reads**, so none of them are implemented.
|
||||
//!
|
||||
//! Written by hand rather than taken from a crate for the reason in
|
||||
//! `Cargo.toml`: this code runs on a stranger's machine over their private game
|
||||
//! library, and the whole dependency tree has to be reviewable.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Node(BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Walk a path of keys, case-insensitively.
|
||||
///
|
||||
/// Steam is inconsistent about capitalisation across versions and
|
||||
/// platforms — `apps` vs `Apps`, `LastPlayed` vs `lastplayed` — and a
|
||||
/// case-sensitive lookup here silently returns nothing on some installs,
|
||||
/// which reads as "this user plays no games" rather than as a bug.
|
||||
pub fn get(&self, path: &[&str]) -> Option<&Value> {
|
||||
let mut cur = self;
|
||||
for want in path {
|
||||
let Value::Node(map) = cur else { return None };
|
||||
cur = map
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(want))
|
||||
.map(|(_, v)| v)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::Str(s) => Some(s),
|
||||
Value::Node(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_node(&self) -> Option<&BTreeMap<String, Value>> {
|
||||
match self {
|
||||
Value::Node(m) => Some(m),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf read as an integer, tolerating the quoted decimals Steam writes.
|
||||
pub fn as_u64(&self) -> Option<u64> {
|
||||
self.as_str()?.trim().parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a whole VDF document into its root node.
|
||||
///
|
||||
/// Malformed input yields whatever was read before the problem rather than an
|
||||
/// error. That is deliberate: a truncated `localconfig.vdf` (Steam was killed
|
||||
/// mid-write) should cost us one field, not the whole run.
|
||||
pub fn parse(input: &str) -> Value {
|
||||
let mut p = Parser {
|
||||
b: input.as_bytes(),
|
||||
i: 0,
|
||||
};
|
||||
Value::Node(p.node(true))
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn node(&mut self, top: bool) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
loop {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
None => return out,
|
||||
Some(b'}') => {
|
||||
if !top {
|
||||
self.i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(key) = self.token() else { return out };
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
self.i += 1;
|
||||
out.insert(key, Value::Node(self.node(false)));
|
||||
}
|
||||
None => return out,
|
||||
_ => match self.token() {
|
||||
Some(v) => {
|
||||
out.insert(key, Value::Str(v));
|
||||
}
|
||||
None => return out,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.i).copied()
|
||||
}
|
||||
|
||||
/// Skip whitespace and `//` comments.
|
||||
fn ws(&mut self) {
|
||||
loop {
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_whitespace()) {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.b[self.i..].starts_with(b"//") {
|
||||
while !matches!(self.peek(), None | Some(b'\n')) {
|
||||
self.i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// One quoted or bare token. Handles `\\` and `\"`; Steam emits both in
|
||||
/// Windows paths inside `libraryfolders.vdf`.
|
||||
fn token(&mut self) -> Option<String> {
|
||||
match self.peek()? {
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.peek()? {
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
return Some(s);
|
||||
}
|
||||
b'\\' => {
|
||||
self.i += 1;
|
||||
match self.peek()? {
|
||||
b'n' => s.push('\n'),
|
||||
b't' => s.push('\t'),
|
||||
c => s.push(c as char),
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
c => {
|
||||
// Push bytes and let String::from_utf8_lossy-style
|
||||
// recovery happen naturally: paths may be non-UTF-8
|
||||
// on Windows, and one bad path must not lose the file.
|
||||
s.push(c as char);
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'{' | b'}' => None,
|
||||
_ => {
|
||||
let start = self.i;
|
||||
while matches!(self.peek(), Some(c) if !c.is_ascii_whitespace() && c != b'{' && c != b'}')
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i == start {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&self.b[start..self.i]).into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaves_nodes_and_comments() {
|
||||
let v = parse(
|
||||
r#"
|
||||
// a comment
|
||||
"AppState"
|
||||
{
|
||||
"appid" "730"
|
||||
"name" "Counter-Strike 2"
|
||||
"SizeOnDisk" "38654705664"
|
||||
"nested" { "a" "1" }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
v.get(&["AppState", "name"]).unwrap().as_str(),
|
||||
Some("Counter-Strike 2")
|
||||
);
|
||||
assert_eq!(
|
||||
v.get(&["appstate", "sizeondisk"]).unwrap().as_u64(),
|
||||
Some(38654705664)
|
||||
);
|
||||
assert_eq!(
|
||||
v.get(&["AppState", "nested", "a"]).unwrap().as_u64(),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_windows_path() {
|
||||
let v = parse(r#" "libraryfolders" { "0" { "path" "D:\\SteamLibrary" } } "#);
|
||||
assert_eq!(
|
||||
v.get(&["libraryfolders", "0", "path"]).unwrap().as_str(),
|
||||
Some(r"D:\SteamLibrary")
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncation must cost one field, not the document.
|
||||
#[test]
|
||||
fn truncated_input_keeps_what_it_read() {
|
||||
let v = parse(r#" "a" "1" "b" { "c" "2" "#);
|
||||
assert_eq!(v.get(&["a"]).unwrap().as_u64(), Some(1));
|
||||
assert_eq!(v.get(&["b", "c"]).unwrap().as_u64(), Some(2));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user