* 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>
89 lines
3.1 KiB
Plaintext
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$'
|
|
}
|