Skip to content

feat(session): surface exec exit-status / exit-signal via SshSession.exitInfo#232

Open
GlassOnTin wants to merge 1 commit into
connectbot:mainfrom
GlassOnTin:feat/exec-exit-status
Open

feat(session): surface exec exit-status / exit-signal via SshSession.exitInfo#232
GlassOnTin wants to merge 1 commit into
connectbot:mainfrom
GlassOnTin:feat/exec-exit-status

Conversation

@GlassOnTin

Copy link
Copy Markdown
Contributor

What

Adds SshSession.exitInfo: Deferred<SessionExit?> — the RFC 4254 §6.10 exit-status / exit-signal reports, which the packet loop previously parsed (the kaitai types were already wired into SshMsgChannelRequest) but dropped after logging. Without this, an exec consumer can read stdout/stderr to EOF but never learn how the command terminated.

  • SessionExit sealed interface: Status(code) / Signal(signalName, coreDumped, errorMessage)
  • Completed from the connection's channel-request dispatch for session channels; first report wins per the RFC
  • Resolves to null when the channel closes without a report (servers aren't required to send one), so waiters are never left suspended
  • Metalava signature updated; metalavaCheckCompatibility passes

Why

Haven is adopting sshlib as its second SSH engine (context in GlassHaven/Haven#58); the next slice is remote command execution, where automations branch on exit codes. This was the one missing piece for exec parity — happy to adjust the API shape (e.g. a suspend accessor instead of the exposed Deferred) if you'd prefer.

Tests

  • 4 FakeSshServer flow tests: exit-status completes exitInfo; exit-signal completes it; close-without-report resolves null; first report wins over a later one
  • 2 Testcontainers integration tests against OpenSSH 9.9p2: sh -c 'exit 42'Status(42); a KILL-terminated command → Signal("KILL", …)
  • Full :sshlib:test suite: 1170 tests, 0 failures (unit + integration, Docker available)

…exitInfo

The kaitai schemas already parse the RFC 4254 §6.10 exit-status and
exit-signal channel requests, but the connection's packet loop dropped
them after logging, so exec consumers had no way to learn a command's
exit code.

Adds a sealed SessionExit (Status / Signal) and a Deferred exitInfo on
SshSession, completed from the packet loop when the server reports
termination and resolved to null when the channel closes without a
report (servers are not required to send one). First report wins per
the RFC.

Tests: four FakeSshServer flow tests (status, signal, close-without-
report resolves null, first-report-wins) plus two Testcontainers
integration tests against OpenSSH 9.9p2 (sh -c 'exit 42' and a
KILL-terminated command). Metalava signature updated;
metalavaCheckCompatibility passes.
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Jul 22, 2026
…p probe (#58)

Phase 3 hit an upstream wall worth documenting: sshlib runs exec but
drops the RFC 4254 §6.10 exit-status/exit-signal reports, so
ExecResult.exitStatus cannot be produced honestly — and run_command
automations branch on exit codes. Upstreamed as connectbot/cbssh#232
(SshSession.exitInfo: Deferred<SessionExit?>, flow + OpenSSH-9.9p2
integration tests, 1170 upstream tests green).

Haven side, landable today: ExecContractTest — the engine-agnostic
exec contract (stdout, stderr, exit codes, 1 MiB output, timeout
shape) against MINA with an in-process scripted CommandFactory, run by
JschExecContractTest as the behaviour pin; plus a spike GAP probe
asserting the released sshlib has no exit API — it fails when a
release carries #232, which is the signal to add SshlibExecContractTest
and the engine-aware exec routing.
@GlassOnTin

Copy link
Copy Markdown
Contributor Author

Some context on where this fits, @kruton, for whenever you next have review bandwidth (CI is green across JDK 17/21/25 + CodeQL).

This closes the last gap for adopting sshlib as a second SSH engine in Haven — the migration you kindly kicked off back in GlassHaven/Haven#58 when you reached out about ssh-proto. Since then the exec (#95), subsystem (#98), SFTP (#112) and FIDO2/SK (#146) work all landed upstream, and Haven now has the per-engine building blocks built and tested against real servers:

  • SFTP — shipped and device-verified on Haven's daily driver.
  • Shell / PTY — done, contract-tested against JSch byte-for-byte (banner-first-bytes, echo, resize, clean-exit).
  • Port forwarding (local / remote / dynamic SOCKS5) — done, contract-tested.

The one thing still missing is a truthful command exit status. Haven runs execCommand to detect tmux/zellij/screen sessions and to bootstrap Mosh/Eternal-Terminal, and those flows branch on the exit code — so the library dropping the RFC 4254 §6.10 exit-status/exit-signal report (the kaitai types already parse it; the packet loop just discards it after a debug log) means the exit code can't be surfaced honestly. Rather than fabricate a value, Haven has that whole path gated behind this PR: once it lands in a release, a sshlib-toggled Haven profile can run its terminal, tunnels and file transfer entirely over sshlib instead of JSch.

Happy to reshape the API if you'd prefer something other than an exposed Deferred<SessionExit?> — e.g. a suspend fun awaitExit(): SessionExit? accessor, or folding it into the existing close/lifecycle signalling. Whatever fits the library best; the wire parsing and tests are the substance.


/** SSH_MSG_CHANNEL_REQUEST `exit-status` (RFC 4254 section 6.10). */
suspend fun sendChannelExitStatus(recipientChannel: Int, exitStatus: Int) {
val payload = ByteArrayOutputStream()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use the SshMsgChannelRequest, ChannelRequestExitStatus, and ChannelRequestExitSignal types since they already support writing.

coreDumped: Boolean = false,
errorMessage: String = "",
) {
val payload = ByteArrayOutputStream()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use the SshMsgChannelRequest, ChannelRequestExitStatus, and ChannelRequestExitSignal types since they already support writing.

// process terminated to exitInfo waiters.
when (val fields = msg.requestSpecificFields()) {
is ChannelRequestExitStatus -> entry.channel.receiveExitInfo(
SessionExit.Status(fields.exitStatus().toInt()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the RFC says this should be uint32, but this is converting to integer here. Prefer Long to preserve the full range. It might be prudent to add a test for the 0xffff_ffffL boundary condition as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants