Files
omarchycn/default/bash/fns/ssh-reconnect
6ddc39520d Clean up the terminal and reconnect when SSH connections drop (#6661)
* Clean up the terminal and reconnect when SSH connections drop

A remote tmux, herdr, or editor arms terminal modes over the SSH pipe
(mouse tracking, focus reporting, the alternate screen) that only it can
disarm. When the connection dies instead of exiting cleanly, those modes
stay armed on the local terminal, and every mouse move floods the prompt
with escape-sequence junk.

Wrap ssh in a shell function that disarms those modes after every exit,
and automatically reconnects when an established interactive session
drops. Remote commands, configured RemoteCommands, and redirected stdin
never reconnect, so their side effects cannot replay, and the retry loop
runs in a subshell so Ctrl-C cancels both the in-flight attempt and the
loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Detect dead SSH connections within a minute

Without keepalives, ssh does not notice a dead peer until TCP gives up,
which can take hours of sitting on a hung terminal with remote-armed
terminal modes stuck on. Ship a client keepalive default so drops are
detected in about 45 seconds, letting the shell's ssh wrapper clean up
and reconnect. ~/.ssh/config is read first and wins, so per-host
overrides still apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail closed when ssh -G cannot resolve the effective config

An unresolvable configuration could hide a RemoteCommand, so treat it
as non-interactive rather than reconnectable. Also strengthen the
tests from Copilot review: assert the complete disarm sequence, and
verify on a real interactive pty that Ctrl-C during a retry attempt
kills the reconnect loop itself, not just the in-flight attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tolerate the explicit RemoteCommand none when probing ssh -G

The literal "none" is how ssh_config cancels a configured
RemoteCommand, and some OpenSSH versions emit it even when unset, which
would have silently disabled reconnecting entirely. Treat it as no
remote command while still failing closed on real ones and unresolvable
configs, and make the fake ssh -G emit the "none" form so the behavior
tests cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 19:15:21 +02:00

89 lines
3.1 KiB
Plaintext

# Wrap ssh to clean up the terminal and reconnect when a connection drops.
#
# A remote tmux, herdr, or editor arms terminal modes over the SSH pipe (mouse
# tracking, focus reporting, the alternate screen) that only it can disarm. If
# the connection dies instead of exiting cleanly, those modes stay armed on the
# local terminal, and every mouse move floods the prompt with escape junk.
ssh() {
local rc started
started=$SECONDS
command ssh "$@"
rc=$?
[[ -t 1 ]] || return $rc
_ssh_disarm
# Reconnect only when an interactive session drops: ssh exits 255 for
# transport failures, but a fast 255 with no established session is a
# connect/auth failure, a remote command's own 255 passes through
# indistinguishably and must not replay its side effects, and redirected
# stdin would feed the remaining piped input to a fresh remote shell.
if (( rc != 255 )) || [[ ! -t 0 ]] || ! _ssh_interactive "$@" ||
(( SECONDS - started < 30 )); then
return $rc
fi
# Retry in a subshell: Ctrl-C reaches the whole foreground process group,
# so it cancels both the in-flight attempt and the loop itself. Keep
# retrying fast failures, since a rebooting server refuses connections too.
(
while true; do
echo "Connection lost. Reconnecting (Ctrl-C to stop)..."
sleep 2
command ssh "$@"
rc=$?
_ssh_disarm
(( rc != 255 )) && exit $rc
done
)
}
# Disarm mouse tracking (1000/1002/1003, 1006 encoding), focus reporting
# (1004), and the alternate screen (1049), and show the cursor again.
_ssh_disarm() {
printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1004l\e[?1049l\e[?25h'
}
# True for an interactive session: a destination and no remote command. The
# letters are the ssh(1) options that consume a value, so their arguments are
# not mistaken for the destination.
_ssh_interactive() {
local value_opts="BbcDEeFIiJLlmOoPpQRSWw"
local argv=("$@") arg letters i dest="" opts_done=""
while (($#)); do
arg="$1"
shift
if [[ -z $opts_done && $arg == "--" ]]; then
opts_done=1
elif [[ -z $opts_done && $arg == -?* ]]; then
letters="${arg#-}"
for ((i = 0; i < ${#letters}; i++)); do
if [[ $value_opts == *"${letters:i:1}"* ]]; then
# The value is glued to the letter (-p2222) unless the letter ends
# the argument, in which case it consumes the next one (-p 2222).
(( i == ${#letters} - 1 )) && shift
break
fi
done
elif [[ -z $dest ]]; then
dest="$arg"
else
return 1
fi
done
[[ -n $dest ]] || return 1
# A RemoteCommand from ssh_config or -o replays on reconnect just like a
# positional command; ssh -G resolves the effective configuration for this
# exact invocation without connecting. Fail closed when it cannot resolve,
# since an undetected RemoteCommand must not replay. The explicit "none"
# cancels a configured command, and some versions emit it when unset.
local resolved
resolved=$(command ssh -G "${argv[@]}" 2>/dev/null) || return 1
! grep -i '^remotecommand ' <<<"$resolved" | grep -qvi '^remotecommand none$'
}