Skip to content

fix(sshx): keep idle connections alive with SSH keepalives - #162

Merged
TooonyChen merged 1 commit into
mainfrom
bugfix/ssh-keepalive
Nov 9, 2026
Merged

TooonyChen merged 1 commit into
mainfrom
bugfix/ssh-keepalive

Conversation

@TooonyChen

Copy link
Copy Markdown
Owner

Summary

Until now the server monitor sent the app's only keepalive. With the host's exec channel off, through a jump host, or after the stats script hung, a quiet session sent nothing, and nothing noticed when a connection died without a word. This PR adds a keepalive to every SSH connection that does not depend on the monitor.

  • sshx.KeepAlive(ctx, client, KeepAliveConfig{Interval, Timeout, MaxMissed}) sends keepalive@openssh.com with want-reply every interval. Any answer counts, success or failure. Each wait has its own timeout, run in a goroutine, because SendRequest has no timeout. While one request is still waiting, no new one is sent: global requests wait for their answers one at a time anyway. After MaxMissed (3) misses in a row it closes the client. The loop ends when ctx ends or the client closes (client.Wait).
  • sshx.Options.KeepAlive: Dial starts the keepalive on the client it returns, and dialJump gives the same config to each jump host's client. A jump host whose keepalive closes it takes down the host's connection through it. With an HTTP or SOCKS proxy, the host's own keepalive crosses the proxy.
  • When the keepalive closes a client, the shell's channel gets EOF and the terminal's existing OnExit sets statusClosed / errConnectionLost, so the session shows "Connection lost" and Reconnect works. The test checks this path. No new status code was needed.
  • Setting keepAliveSeconds (Options › General › Connections, "Send a keepalive every N seconds", 0 to 3600). Each wait times out after min(interval, 15 s).
    • Storage: the field is an int with a sentinel value. Missing or 0 means the default of 30, and the options' 0 ("off") is stored as -1. This follows the repo's existing rule that zero means default (wheelLines, termFontSize, sync.interval). It keeps Settings a comparable value (tests compare with ==, and a *int would compare addresses). It also keeps the sync rule that a setting at its default has no key. The downside is that a hand-written 0 in settings.json means 30. configuration.md says so.
    • Page: General, not Network monitor. The "Network monitor" page is about the Network tab (IP locations, trace route), and General already holds app-wide behavior.
    • Synced: added to syncedSettings, like the other behavior settings.
    • It applies to connections made after the change. The Options intro in features.md now lists it as an exception to "takes effect at once".
  • monitor.Run: runOutput now gives up after 10 s. It closes that exec session and keeps the connection, so a stuck df no longer stops the monitor loop.
  • internal/sshtest: counts keepalives (KeepAlives), can stop answering them (StopAnswering), and can accept exec requests that never finish (HangExec).
  • Docs: features.md (Sessions and tabs, Server monitor, Options › General) and configuration.md (keepAliveSeconds), in both languages.

CHANGELOG.md only takes version sections, so here is a ready bullet for the next release:

  • English: Sessions no longer drop when left idle: every connection, and each jump host's, sends an SSH keepalive every 30 seconds even with the server monitor off, and a connection that stops answering shows "Connection lost" so you can reconnect. Change the interval, or turn it off with 0, in Options › General.
  • 简体中文:会话空闲时不再被断开:每个连接(以及途经的跳板机)每 30 秒发送一次 SSH 保活消息,服务器监控关闭时也一样;连接没有回应时显示“连接已断开”,可以重新连接。间隔可在“选项 › 常规”里修改,设为 0 关闭。

Closes #161

Testing

New tests:

  • internal/app TestKeepAliveIdleSession: a session to sshtest with the monitor off gets keepalives at the interval. After StopAnswering, the session becomes statusClosed / "Connection lost". Fails on main in both parts: 0 keepalives, want 3, and, with that step removed, status "Connected" after the server stopped answering.
  • internal/monitor TestRunScriptHangs: with HangExec, the monitor keeps publishing stats. Fails on main: 0 stats in 3 s with the script hanging.
  • TestKeepAliveJumpHost (app) and TestDialKeepAliveJumpHost (sshx): keepalives reach both the jump host and the target. In the sshx test, a jump host that stops answering closes the host's client.
  • TestKeepAlive and TestKeepAliveEnds (sshx): the interval, closing after 3 misses, and stopping when ctx ends or the client closes. TestKeepAliveOff: the setting at 0 sends none.
  • TestKeepAliveOption: the Options control, its 30 default, 0 saved as -1, and back to default (nothing saved). TestKeepAliveSetting (config): the mapping and sync.

The tests use 10 to 50 ms intervals and add about 1.5 s in total.

Checks: go fmt ./... changes nothing, and go build ./..., go vet ./... (also GOOS=windows and GOOS=darwin) and go tool mygo vet pass. go test -race ./... passes except TestArchiveTreeMenu, which also fails in this sandbox on main because it tars the real /tmp. I looked at screenshots of the General page in English and Simplified Chinese.

Not tested: a real NAT, firewall or proxy dropping an idle connection, and a real OpenSSH server. The in-process server stands in for both.


Generated by Claude Code

Send keepalive@openssh.com on every session's connection and on each
jump host's, independent of the server monitor, every keepAliveSeconds
(30 by default, 0 off, in Options > General). Any answer counts; after
three unanswered in a row the client is closed, so the session shows
"Connection lost" instead of looking connected.

The monitor's stats script now gives up after 10 seconds, so a hanging
script no longer stops the monitor loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nMqJHsqtndDSqJDPp1qYv
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T23:09:12.091207Z c4113c0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@TooonyChen
TooonyChen merged commit ffc5a1a into main Nov 9, 2026
1 check passed
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.

fix(sshx): keep idle connections alive with SSH keepalives

2 participants