fix: report a real exit code from bssh ping - #250
Conversation
`ping_nodes` counted per-host successes and failures only to print them and then returned `Ok(())` unconditionally, so `bssh -H unreachable-host ping; echo $?` printed 0 and any script branching on `bssh ping` succeeding passed unconditionally, while the help text promised "0 (all reachable), 1 (any unreachable)". Ping now follows a 0/1/255 contract: 0 when every targeted host connected and authenticated, 1 when at least one host was reachable and at least one failed, and 255 when no host succeeded or when bssh failed before it could attempt any connection. The 0/1 boundary is `ExitCodeStrategy::RequireAllSuccess`, since a health check is green only when every node is green; `MainRank` does not apply because ping runs no user command whose status could be forwarded. The 255 value follows OpenSSH's convention for "ssh itself encountered an error". `ping_nodes` returns a `PingOutcome` tally instead of `Result<()>`, which is a source break for library consumers, `dispatch_command` returns `Result<i32>`, and `main::dispatch_and_exit` is the single place that converts a nonzero command-level code into the process exit status. `main::map_hard_failure` maps a hard `Err` on the ping path to 255 so a pre-connection failure stays distinct from the 1 that means partial failure; every other subcommand keeps the generic exit code 1. The `long_about` also claimed ping reports "response times" while printing no timing at all. The claim is removed rather than implemented; per-host timing remains a separate proposal. Validated with `cargo test --lib commands::ping` (5 passed), `cargo test --test ping_exit_code_test` (8 passed), `cargo test --test exit_code_integration_test` (17 passed), `cargo test --bin bssh` (51 passed), `cargo test --test pdsh_compat_test` (35 passed), and `cargo clippy --lib --bins --tests -- -D warnings`. Manually verified that `bssh -H unreachable-host ping; echo $?` now prints 255. Refs #245
The default MainRank exit code strategy forwards a remote command's exit status verbatim, so `bssh -H host "exit 255"` already exits 255 today, which the man page's exit status table documents under `1-255 Main rank failed with this exit code`. ARCHITECTURE.md and exit-code-strategy.md both stated that 255 is produced only by ping, which contradicts that behavior and the very next bullet in ARCHITECTURE.md ("Other: Preserved from main rank"). Both statements are reworded to say that ping is the only path that generates 255 as a bssh-level signal, while the exec path can still report 255 by forwarding that status from a remote command under MainRank.
Refs #245
Implementation Review SummaryIntentMake Contract verification against the real process pathAll four rows were exercised end to end against the built binary, not just at the
The value asserted at the The collapse risk called out in the issue is genuinely avoided. Scoping and regression checks
Findings Addressed
Remaining Items
Documentation accuracy
Verification
|
`format_summary` took `nodes.len()` for the total while the succeeded and failed columns came from `PingOutcome`, so `PingOutcome::total` was written but never read outside tests. The two agree today because ping never enables fail-fast, but sourcing all three columns from the same tally removes the chance of them drifting apart if that changes.
Summary
bssh pingalways exited 0, even when every target host was unreachable, while its help text promised0 (all reachable), 1 (any unreachable).ping_nodescounted per-host successes and failures only to print them, returnedOk(())unconditionally, and neither the dispatcher normaintranslated anything, so every script branching onbssh pingsucceeding passed unconditionally. This implements the 0/1/255 mapping signed off on the issue.Exit code contract
The 0/1 boundary aligns with
ExitCodeStrategy::RequireAllSuccess: ping is a health check, so it is green only when every node is green.ExitCodeStrategy::MainRankdoes not apply, because ping runs no user command whose status could be forwarded. 255 follows OpenSSH, which reserves it for "ssh itself encountered an error"; since ping has no remote command, every total failure is by definition an ssh-level failure, and the split lets a caller tell a partially degraded cluster apart from one it could not reach at all.How the exit code is wired through
ping_nodesreturnsResult<PingOutcome>instead ofResult<()>.PingOutcome { total, succeeded, failed }carries the tally andPingOutcome::exit_code()applies the table above.dispatch_commandreturnsResult<i32>(every other arm returns 0), andmain::dispatch_and_exitis the single place that turns a nonzero command-level code into the process exit status, rather than scatteringstd::process::exitcalls across command implementations. The pre-existingexecpath still exits fromsrc/commands/exec.rsafter applying its ownExitCodeStrategy; that is untouched.main::map_hard_failurehandles the other half: a hardErron the ping path, whether raised byinitialize_app(config load failure, no host resolved) or by the executor before any per-host tally exists, prints the chain and exits 255 instead of collapsing into the 1 that means "some hosts answered and some did not". The mapping is scoped toSome(Commands::Ping); every other subcommand keeps the default, where returningErrfrommainexits 1. A test pins that scoping. Command-line usage errors are still rejected by clap with its own exit code 2, unchanged and identical across subcommands; that is stated in both the man page and the architecture doc rather than silently assumed.What changed
src/commands/ping.rs: addedPING_SSH_LEVEL_FAILURE(255),PingOutcome,PingOutcome::from_results, andPingOutcome::exit_code;ping_nodesnow returns the tally. The two local counters are gone, and the summary line is fed from the same tally so display and exit code cannot disagree.src/app/dispatcher.rs:dispatch_commandreturnsResult<i32>; theSome(Commands::Ping)arm translates the outcome, all other arms returnEXIT_SUCCESS.src/main.rs: addeddispatch_and_exitandmap_hard_failure; bothrun_bssh_modeandrun_pdsh_moderoute through them.src/cli/bssh.rs: thePinglong_aboutnow lists 0/1/255 and drops the false "response times" claim (the implementation prints no timing at all). Per the decision on the issue, the text is corrected rather than timing implemented.docs/man/bssh.1:.SH EXIT STATUSgains aping Subcommandsubsection so the man page and--helpagree.docs/architecture/exit-code-strategy.md: new "The ping Contract" section covering the table, the rationale for 255, the implementation, and the error propagation rule; the file structure listing now includesping.rsanddispatcher.rs.ARCHITECTURE.md: the Exit Code Strategy summary and the Exit Codes list record the ping exception and 255.CHANGELOG.md: a Fixed entry for the user-visible behavior change (previously always 0, all-unreachable now 255) and a Changed entry for theping_nodessignature, which is a source break for library consumers.Tests
src/commands/ping.rsunit tests (5): exit code 0, 1, 255, and the empty host list, plus a parity test assertingPingOutcome::exit_code()equalsExitCodeStrategy::RequireAllSuccess.calculate()for every case where at least one host answered, so the two cannot drift apart.tests/ping_exit_code_test.rs(8, new), following thetests/exit_code_integration_test.rsprecedent: four contract tests overPingOutcome, which is the exact valuemainhands tostd::process::exit, covering 0, 1, 255, and the empty list; plus four process-level tests that run the real binary and assert the observed status: all hosts unreachable exits 255, a config file that cannot be loaded exits 255, a host list that resolves to nothing exits 255, and a non-ping subcommand on the same pre-connection failure still exits 1.Scope note on the process-level layer: the all-success (0) and partial-failure (1) cases need a host that actually accepts an SSH connection, which no offline test can provide, so those two are asserted at the
PingOutcomeboundary rather than by spawning the binary. The three cases that are deterministic offline are asserted end to end against the real process.Test plan
cargo test --lib commands::ping(5 passed)cargo test --test ping_exit_code_test(8 passed)cargo test --test exit_code_integration_test(17 passed, unchanged)cargo test --bin bssh(51 passed),cargo test --test pdsh_compat_test(35 passed)cargo clippy --lib --bins --tests -- -D warningsclean,cargo fmt --allbssh -H unreachable-host ping; echo $?prints 255 (was 0)bssh --config /nonexistent-dir/config.yaml -H h1 pingexits 255,... -H h1 --filter no-such pingexits 255, and the same config failure with an exec command still exits 1man ./docs/man/bssh.1renders the new EXIT STATUS subsection correctlyCloses #245