fix: locale-safe metrics, ssh_config Include resolution, and reachability-only monitoring - #79
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
topprints the idle share and we show the rest as used. On a server whose system language uses a comma decimal separator it prints99,1 id, andparse_cpu_linux_top_linesplit that line on,— so it read the idle value as1and reported 99 % used on a machine doing nothing. Reproduced ondebian:trixie(procps 4.0.4) withLANG=de_DE.UTF-8: the real parser returnsSome(99.0)for a 0.9 % load. The reporter's guess was that we read%idleinstead of%usage;100 − idleis deliberate, the comma split is the bug.The same locale silently broke more than CPU:
freeprintsSpeicher:so RAM went N/A,dftranslatesUse%so Disk went N/A with no fallback at all,uptimeput its whole tail into the uptime field, andpscomma-formatspcpuso the process list came back empty.env LC_ALL=C.pspipeline usesenv LC_ALL= LC_NUMERIC=C LC_MESSAGES=Cinstead —LC_ALLoutranks every other category, so it has to be cleared rather than set, and leavingLC_CTYPEto the host keeps non-ASCII process names intact.pscolumns normalise a decimal comma themselves, so a host withoutenvdegrades to the right number instead of a plausible wrong one.Hosts behind
Includeare 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, realssh -G→ 4.Fixed by anchoring a relative pattern to the config's own directory (
ssh_config(5):~/.sshfor a user config). Four more defects in the same code path went with it, all of which failed silently:*prefix/suffix match —?,[…], a wildcard in a directory component and a second*all matched nothing; it is nowglob(7)via theglobcrate;Includeline dropped all of them;Includeinside aHostblock discarded the rest of that block, so the host came back with its alias as hostname and the default user.An
Includethat 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/freefails 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 ignoreskeepalive@openssh.comwas 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.inactivity_timeoutis dropped;keepalive_maxstill 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:PORTin 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 newHostfields are serde-defaulted and skipped when unset, so an existinghosts.tomlis 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
ProxyJumpwith 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_RAWor an opennet.ipv4.ping_group_rangeon Linux, and the AppImage cannotsetcap, 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
Fixeson that line and leave the issue open.CONTRACT GAP — resolved
tech-gui.md §4.1did not define a monitoring mode on the wire. Recorded there before implementing:HostDto/HostInputDtogainmonitoring+monitorPort?, plusMonitorModeDto. No new command and no new event —ConnectionStatusDtoalready carries the probe result, andMetricsDtois untouched because a reachability host reports none.bindings.tsis regenerated, never hand-edited;committed_bindings_are_in_syncproves it.Review
Two rounds of
/code-reviewathighplus two independent adversarial agents. What they caught, all fixed here:wait_backoffbusy-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 theIncludefix makes duplicate names more likely.PS_LOCALEwas a no-op —LC_ALLoutranksLC_NUMERIC, so it did nothing on precisely the hosts it was for.recv(), so a regression hung CI instead of failing it. Both are now bounded and verified red on revert.alertbucket forever.Every fix has a test that fails when the fix is reverted — verified one hunk at a time in scratch worktrees.
Waivers
backoff.reset()placement has no behavioural test: covering it needs an SSH server fake that authenticates and then failsexec, plusknown_hostsisolation. 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.AppConfig.general(includingrefresh_intervalandmax_concurrent_connections) has no consumer;run_commandignores the remote exit status andcollect_outputdiscards stderr, so a stderr-only failure reads as an empty success;/etc/ssh/ssh_configis never parsed;Matchblocks are not handled and leak their directives into the precedingHost;Host a b cis 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.