Skip to content

fix: locale-safe metrics, ssh_config Include resolution, and reachability-only monitoring - #79

Merged
timhartmann7 merged 13 commits into
mainfrom
fix/locale-include-and-poll-churn
Aug 19, 2026
Merged

fix: locale-safe metrics, ssh_config Include resolution, and reachability-only monitoring#79
timhartmann7 merged 13 commits into
mainfrom
fix/locale-include-and-poll-churn

Conversation

@timhartmann7

@timhartmann7 timhartmann7 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Three field-reported defects, and the feature the third one asked for.

Fixes #70
Fixes #66
Fixes #45

CPU reads ~91–100 % on an idle server (#70)

top prints the idle share and we show the rest as used. On a server whose system language uses a comma decimal separator it prints 99,1 id, and parse_cpu_linux_top_line split that line on , — so it read the idle value as 1 and reported 99 % used on a machine doing nothing. Reproduced on debian:trixie (procps 4.0.4) with LANG=de_DE.UTF-8: the real parser returns Some(99.0) for a 0.9 % load. The reporter's guess was that we read %idle instead of %usage; 100 − idle is deliberate, the comma split is the bug.

The same locale silently broke more than CPU: free prints Speicher: so RAM went N/A, df translates Use% so Disk went N/A with no fallback at all, uptime put its whole tail into the uptime field, and ps comma-formats pcpu so the process list came back empty.

  • The parsed commands now run under env LC_ALL=C.
  • The ps pipeline uses env LC_ALL= LC_NUMERIC=C LC_MESSAGES=C instead — LC_ALL outranks every other category, so it has to be cleared rather than set, and leaving LC_CTYPE to the host keeps non-ASCII process names intact.
  • Both CPU parsers and the ps columns normalise a decimal comma themselves, so a host without env degrades to the right number instead of a plausible wrong one.

Hosts behind Include are not imported (#66)

Include conf.d/*.conf — the form nearly every split-config guide prints — was resolved against the process working directory, which for a Finder-launched app is /. Reproduced against the reporter's exact tree: cwd=/ → 1 host, cwd=~/.ssh → 4, real ssh -G → 4.

Fixed by anchoring a relative pattern to the config's own directory (ssh_config(5): ~/.ssh for a user config). Four more defects in the same code path went with it, all of which failed silently:

  • the glob engine was a single-* prefix/suffix match — ?, […], a wildcard in a directory component and a second * all matched nothing; it is now glob(7) via the glob crate;
  • several pathnames on one Include line dropped all of them;
  • a quoted path kept its literal quotes;
  • an Include inside a Host block discarded the rest of that block, so the host came back with its alias as hostname and the default user.

An Include that matches nothing is now logged. A relative one with no home directory to anchor it is dropped rather than silently resolved against the working directory.

Appliances with no shell, and the login storm (#45)

A device that authenticates but cannot answer top/free fails every metric round, and that was treated as a dead session. Because the retry delay was reset on every successful connect, it never escalated past its first step: one login every 30 s, forever, and as often as every 10 s in the desktop app, whose refresh timer also cut the delay short. A device that ignores keepalive@openssh.com was additionally torn down after 30 s even while its commands worked, because russh does not reset the inactivity timer on the iteration that sends a keepalive.

  • The backoff resets on a cycle that produced data, so it escalates 30 → 60 → 120 → 300.
  • The backoff wait ignores refresh nudges. Trade-off: a manual refresh no longer shortens a retry delay — at the core boundary it is indistinguishable from the GUI's timer. Noted in the CHANGELOG; making the two distinguishable is a follow-up.
  • inactivity_timeout is dropped; keepalive_max still bounds a dead peer, at ~75 s instead of ≤30 s.

And the feature that was actually asked for

A per-host monitoring mode. tcp / tcp:PORT in the TUI form, a dropdown in the desktop app: one TCP connect per cycle, no session and no authentication. The card shows the probe result and names the port it dialled — a green line for 8443 must not read as "SSH is up". Both new Host fields are serde-defaulted and skipped when unset, so an existing hosts.toml is byte-identical, and the SSH poller is untouched.

The probe emits a status change rather than an event per cycle, backs off on failure like the SSH poller, and refuses a host behind ProxyJump with an explicit message instead of dialling the target address direct, where anything else on that address would answer for it.

ICMP is deliberately not included: unprivileged ping needs CAP_NET_RAW or an open net.ipv4.ping_group_range on Linux, and the AppImage cannot setcap, so it would degrade silently on the most common target. TCP is honest about what it checks.

#45 asked for "ICMP checks or basic port reachability", so this closes it on the port-reachability half. If ICMP should stay tracked, drop the Fixes on that line and leave the issue open.

CONTRACT GAP — resolved

tech-gui.md §4.1 did not define a monitoring mode on the wire. Recorded there before implementing: HostDto/HostInputDto gain monitoring + monitorPort?, plus MonitorModeDto. No new command and no new eventConnectionStatusDto already carries the probe result, and MetricsDto is untouched because a reachability host reports none. bindings.ts is regenerated, never hand-edited; committed_bindings_are_in_sync proves it.

Review

Two rounds of /code-review at high plus two independent adversarial agents. What they caught, all fixed here:

  • Blocker: wait_backoff busy-spun at 100 % of a core once every refresh sender was dropped — measured 25.5 M iterations in 2 s. Two hosts sharing a name are enough to reach it, and the Include fix makes duplicate names more likely.
  • Blocker: my first PS_LOCALE was a no-op — LC_ALL outranks LC_NUMERIC, so it did nothing on precisely the hosts it was for.
  • Blocker: the first two integration tests looped on an unbounded recv(), so a regression hung CI instead of failing it. Both are now bounded and verified red on revert.
  • The TUI never restarted the pollers after an edit, so a mode change was written to disk and then ignored until relaunch.
  • A reachability host kept its last SSH sample, pinning it in the status bar's alert bucket forever.
  • The detail view still drew metric bars for a reachability host; the form's ninth field fell off a short terminal.

Every fix has a test that fails when the fix is reverted — verified one hunk at a time in scratch worktrees.

Waivers

  • The backoff.reset() placement has no behavioural test: covering it needs an SSH server fake that authenticates and then fails exec, plus known_hosts isolation. The unit test documents the schedule; the integration test covers the connect-failure path and the refresh-preemption fix.
  • upsert's "monitoring omitted means unchanged" branch is unreachable from the app today (the form always sends the field). Kept as boundary hardening, exercised by its own test.
  • Pre-existing and untouched, found while reading: AppConfig.general (including refresh_interval and max_concurrent_connections) has no consumer; run_command ignores the remote exit status and collect_output discards stderr, so a stderr-only failure reads as an empty success; /etc/ssh/ssh_config is never parsed; Match blocks are not handled and leak their directives into the preceding Host; Host a b c is stored as one host named "a b c".

Gates

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (513 passed), npm run check (0 errors), npm test (215 passed), npx knip (no new unused exports), scripts/hygiene-guard.sh. Every one of the 13 commits compiles on its own.

A host that authenticates but cannot answer the metric commands - a network
appliance with no shell - failed every cycle, and the reset on a successful
connect pinned it to the first backoff step: one login every 30 s, forever.
Reset on a cycle that produced data instead, and stop letting a refresh signal
cut the backoff wait short, which let the GUI's refresh timer retry a failing
host every few seconds.
russh skips resetting the inactivity timer on the iteration that sends a
keepalive, so a peer that never answers keepalive@openssh.com was disconnected
after 30 s even while its commands still ran - and the poller then re-logged in
on every cycle. Drop the inactivity timeout; keepalive_max still bounds a dead
peer.
On a server whose system language uses a comma decimal separator, top prints
"99,1 id". The CPU parser splits that line on ',', read the tenths digit as the
idle value and reported 100 minus it - a near-constant 91-100 % on an idle
machine - while free and df translate their labels and left RAM and Disk at N/A.

Run the parsed commands under LC_ALL=C, and LC_NUMERIC/LC_MESSAGES only for the
ps pipeline, whose output reaches the user and must keep the host's LC_CTYPE.
The two CPU parsers also normalise a decimal comma themselves, so a host without
env can no longer turn a comma into a plausible-looking percentage.
A relative pattern such as "Include conf.d/*.conf" was looked up in whatever
directory the app was launched from - "/" for a GUI started from Finder - so
every host in a conf.d split silently went missing. ssh_config(5) resolves those
against ~/.ssh.

Anchor the pattern to the config's own directory, and fix what the same code
path got wrong alongside it: full glob(7) instead of a single '*' in the file
name, several pathnames on one Include line, quoted paths, and an Include inside
a Host block no longer discarding the rest of that block. An Include that
matches nothing is now logged rather than dropped in silence.
Once every refresh sender is dropped, recv() returns Ready(None) immediately and
forever, so the select loop re-armed without ever yielding - one core pinned for
the whole delay. Two hosts sharing a name reach it: refresh_txs is keyed by
name, so the first poller's sender is dropped while its task still runs.

The new poller test also covers what the backoff change itself lacked: it fails
if a refresh signal is allowed to cut the wait short.
LC_ALL outranks every other LC_* category, so setting LC_NUMERIC alone did
nothing on a host that exports LC_ALL - the very hosts the pin is for. Clear it
instead, which still leaves LC_CTYPE to the host so process names keep their
own character set, and normalise a decimal comma in the ps columns for hosts
the pin cannot reach.
The base directory is a real path, not a pattern, so a home containing a glob
metacharacter turned every relative Include into a pattern that matched
nothing. Escape it before joining. A relative Include with no home directory to
anchor it is now dropped with a warning rather than silently resolved against
the process working directory, which is the defect this parser was fixed for.
Same hole as the backoff wait: once every sender is dropped, recv() completes
immediately and forever, so returning on it made the poll loop spin. Serve out
the delay instead.
Metrics need a POSIX shell, so a network appliance can only ever fail the metric
round - and gets logged in to again on every cycle for the privilege. A host can
now be watched by a plain TCP connect instead: no session, no authentication and
no metrics, only reachability.

The probe reports a status change rather than every cycle, backs off on failure
the way the SSH poller does, and refuses a host behind ProxyJump instead of
dialling the target address direct, where anything else on that address would
answer for it. Both new fields are serde-defaulted and skipped when unset, so an
existing hosts.toml is byte-identical and the SSH poller is untouched.
The form takes 'ssh', 'tcp' or 'tcp:PORT'; the card and the detail view show the
probe result, naming the port it dialled, instead of metric rows for numbers
that were never collected. An edit now restarts the pollers, without which a
mode change was written to disk and then ignored until the next launch, and the
form sizes itself to its fields so the last one survives a short terminal.
Resolves the CONTRACT GAP for the per-host monitoring mode: HostDto and
HostInputDto carry it, MonitorModeDto is the wire enum, and bindings.ts is
regenerated. No new command and no new event - ConnectionStatusDto already
carries the probe result.

An omitted mode on save means unchanged, not back to SSH, and the status bar
ignores the metrics a reachability host stopped refreshing, which would
otherwise pin it in the alert bucket until the app restarted.
@timhartmann7
timhartmann7 merged commit a0cf67d into main Aug 19, 2026
6 checks passed
@timhartmann7
timhartmann7 deleted the fix/locale-include-and-poll-churn branch August 19, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant