Add auto-reconnect with exponential backoff

Sessions configured with auto-reconnect will automatically attempt to
re-establish the connection when unexpectedly disconnected. Uses
exponential backoff (1s, 2s, 4s, 8s... up to 30s) with a configurable
maximum number of attempts (default 5).

- New SessionReconnecting state with pulsing indicator in tab bar
- SSH and Telnet profiles gain autoReconnect and maxReconnectTries fields
- RAW protocol already had autoReconnect; now uses the shared mechanism
- CancelReconnect API to abort in-progress reconnection
- Frontend shows reconnect attempt notifications
- Profile editor exposes toggle and max attempts in Advanced/Reconnection
This commit is contained in:
2026-04-17 00:00:00 +01:00
parent f42662c44e
commit e56496d423
10 changed files with 314 additions and 16 deletions
+8
View File
@@ -125,9 +125,17 @@
.tab-status.is-connected { background: var(--success); } .tab-status.is-connected { background: var(--success); }
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } .tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
.tab-status.is-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; }
.tab-status.is-error { background: var(--error); } .tab-status.is-error { background: var(--error); }
.tab-status.is-disconnected { background: var(--text-tertiary); } .tab-status.is-disconnected { background: var(--text-tertiary); }
/* Also match the status-* pattern used by updateSessionState */
.tab-status.status-connected { background: var(--success); }
.tab-status.status-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
.tab-status.status-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; }
.tab-status.status-error { background: var(--error); }
.tab-status.status-disconnected { background: var(--text-tertiary); }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
SFTP toggle button — revealed on hover (SSH tabs only) SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
+12 -1
View File
@@ -134,10 +134,21 @@ class App {
this.terminals.handleData(payload.sessionID, payload.data); this.terminals.handleData(payload.sessionID, payload.data);
}); });
// Session state transitions (connecting → connected → disconnected) // Session state transitions (connecting → connected → reconnecting → disconnected)
window.runtime.EventsOn('session:state', (payload) => { window.runtime.EventsOn('session:state', (payload) => {
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error); this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
this.updateStatusBar(); this.updateStatusBar();
// Show reconnecting notification
if (payload.state === 'reconnecting') {
const attempt = payload.attempt || 1;
const maxRetries = payload.maxRetries || 5;
const delaySec = payload.delaySec || 1;
this.notifications.info(
`Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…`
);
}
// Once connected, ensure the backend PTY has the correct dimensions. // Once connected, ensure the backend PTY has the correct dimensions.
// The initial fit happens before the SSH handshake completes, so the // The initial fit happens before the SSH handshake completes, so the
// onResize callback fires when the backend isn't ready. By the time // onResize callback fires when the backend isn't ready. By the time
+15
View File
@@ -406,6 +406,10 @@ class ProfileEditor {
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }), this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }), this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression), this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
}),
this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }),
], true)); ], true));
return sections; return sections;
@@ -447,6 +451,13 @@ class ProfileEditor {
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug), this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
], true)); ], true));
sections.push(this._renderSection('Reconnection', [
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
}),
this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }),
], true));
return sections; return sections;
} }
@@ -908,6 +919,8 @@ class ProfileEditor {
p.ssh.compression = val('ssh.compression') || false; p.ssh.compression = val('ssh.compression') || false;
p.ssh.agentAuth = val('ssh.agentAuth') || false; p.ssh.agentAuth = val('ssh.agentAuth') || false;
p.ssh.agentForwarding = val('ssh.agentForwarding') || false; p.ssh.agentForwarding = val('ssh.agentForwarding') || false;
p.ssh.autoReconnect = val('ssh.autoReconnect') || false;
p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5;
p.ssh.startupCommand = val('ssh.startupCommand') || ''; p.ssh.startupCommand = val('ssh.startupCommand') || '';
const authStr = val('ssh.authMethods'); const authStr = val('ssh.authMethods');
@@ -970,6 +983,8 @@ class ProfileEditor {
p.telnet.encoding = val('telnet.encoding') || 'utf-8'; p.telnet.encoding = val('telnet.encoding') || 'utf-8';
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf'; p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false; p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
} }
// RAW-specific // RAW-specific
+1
View File
@@ -251,6 +251,7 @@ function stateLabel(state) {
case 'connecting': return 'Connecting'; case 'connecting': return 'Connecting';
case 'connected': return 'Connected'; case 'connected': return 'Connected';
case 'disconnected': return 'Disconnected'; case 'disconnected': return 'Disconnected';
case 'reconnecting': return 'Reconnecting';
case 'error': return 'Error'; case 'error': return 'Error';
default: return state || 'Unknown'; default: return state || 'Unknown';
} }
+2
View File
@@ -3,6 +3,8 @@
import {models} from '../models'; import {models} from '../models';
import {context} from '../models'; import {context} from '../models';
export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>; export function CheckForUpdates():Promise<Record<string, any>>;
export function ClearSessionHistory(arg1:string):Promise<void>; export function ClearSessionHistory(arg1:string):Promise<void>;
+4
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1);
}
export function CheckForUpdates() { export function CheckForUpdates() {
return window['go']['api']['Service']['CheckForUpdates'](); return window['go']['api']['Service']['CheckForUpdates']();
} }
+8
View File
@@ -284,6 +284,8 @@ export namespace models {
encoding: string; encoding: string;
negotiationDebug: boolean; negotiationDebug: boolean;
lineMode: boolean; lineMode: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new TelnetConfig(source); return new TelnetConfig(source);
@@ -297,6 +299,8 @@ export namespace models {
this.encoding = source["encoding"]; this.encoding = source["encoding"];
this.negotiationDebug = source["negotiationDebug"]; this.negotiationDebug = source["negotiationDebug"];
this.lineMode = source["lineMode"]; this.lineMode = source["lineMode"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
} }
} }
export class SSHConfig { export class SSHConfig {
@@ -311,6 +315,8 @@ export namespace models {
compression: boolean; compression: boolean;
agentAuth: boolean; agentAuth: boolean;
agentForwarding: boolean; agentForwarding: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
jumpHosts: JumpHost[]; jumpHosts: JumpHost[];
portForwards: PortForward[]; portForwards: PortForward[];
startupCommand: string; startupCommand: string;
@@ -333,6 +339,8 @@ export namespace models {
this.compression = source["compression"]; this.compression = source["compression"];
this.agentAuth = source["agentAuth"]; this.agentAuth = source["agentAuth"];
this.agentForwarding = source["agentForwarding"]; this.agentForwarding = source["agentForwarding"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost); this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
this.portForwards = this.convertValues(source["portForwards"], PortForward); this.portForwards = this.convertValues(source["portForwards"], PortForward);
this.startupCommand = source["startupCommand"]; this.startupCommand = source["startupCommand"];
+5
View File
@@ -276,6 +276,11 @@ func (s *Service) ReconnectSession(sessionID string) error {
return s.sessions.Reconnect(sessionID, s.promptSecret) return s.sessions.Reconnect(sessionID, s.promptSecret)
} }
// CancelReconnect cancels an in-progress auto-reconnect for a session.
func (s *Service) CancelReconnect(sessionID string) {
s.sessions.CancelReconnect(sessionID)
}
// WriteSession writes base64-encoded data to a session. // WriteSession writes base64-encoded data to a session.
func (s *Service) WriteSession(sessionID string, data string) error { func (s *Service) WriteSession(sessionID string, data string) error {
decoded, err := base64.StdEncoding.DecodeString(data) decoded, err := base64.StdEncoding.DecodeString(data)
+11 -6
View File
@@ -95,6 +95,8 @@ type SSHConfig struct {
Compression bool `json:"compression"` Compression bool `json:"compression"`
AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
JumpHosts []JumpHost `json:"jumpHosts"` JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"` PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"` StartupCommand string `json:"startupCommand"`
@@ -133,12 +135,14 @@ type PortForward struct {
// TelnetConfig holds Telnet-specific settings for a connection profile. // TelnetConfig holds Telnet-specific settings for a connection profile.
type TelnetConfig struct { type TelnetConfig struct {
LocalEcho bool `json:"localEcho"` LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"` LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"` TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"` Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"` NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"` LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
} }
// RAWConfig holds RAW TCP-specific settings for a connection profile. // RAWConfig holds RAW TCP-specific settings for a connection profile.
@@ -196,6 +200,7 @@ const (
SessionConnecting SessionState = "connecting" SessionConnecting SessionState = "connecting"
SessionConnected SessionState = "connected" SessionConnected SessionState = "connected"
SessionDisconnected SessionState = "disconnected" SessionDisconnected SessionState = "disconnected"
SessionReconnecting SessionState = "reconnecting"
SessionError SessionState = "error" SessionError SessionState = "error"
) )
+248 -9
View File
@@ -27,15 +27,17 @@ import (
// Session wraps a protocol client with metadata and state. // Session wraps a protocol client with metadata and state.
type Session struct { type Session struct {
Info models.SessionInfo Info models.SessionInfo
sshClient *sshclient.Client sshClient *sshclient.Client
telnetClient *telnetclient.Client telnetClient *telnetclient.Client
rawClient *rawclient.Client rawClient *rawclient.Client
githubClient *githubclient.Client githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine githubCancel context.CancelFunc // stops the auto-refresh goroutine
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording recorder *recording.Recorder // active recording, nil if not recording
reconnectCount int // number of reconnection attempts so far
cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff
} }
// Manager handles the lifecycle of all active sessions. // Manager handles the lifecycle of all active sessions.
@@ -1119,6 +1121,12 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode
// closeSession shuts down the protocol client for a session. // closeSession shuts down the protocol client for a session.
func (m *Manager) closeSession(sess *Session) { func (m *Manager) closeSession(sess *Session) {
// Cancel any in-progress reconnect backoff.
if sess.cancelReconn != nil {
sess.cancelReconn()
sess.cancelReconn = nil
}
// Stop any active recording. // Stop any active recording.
if sess.recorder != nil { if sess.recorder != nil {
sess.recorder.Close() sess.recorder.Close()
@@ -1160,6 +1168,12 @@ func (m *Manager) handleSessionClosed(sessionID string) {
m.auditLog.LogDisconnect(sessionID) m.auditLog.LogDisconnect(sessionID)
// Check whether auto-reconnect is enabled for this session.
if ok && m.shouldAutoReconnect(sess) {
m.scheduleReconnect(sess, sessionID)
return
}
m.emitEvent("session:closed", map[string]interface{}{ m.emitEvent("session:closed", map[string]interface{}{
"sessionID": sessionID, "sessionID": sessionID,
}) })
@@ -1169,6 +1183,231 @@ func (m *Manager) handleSessionClosed(sessionID string) {
}) })
} }
// shouldAutoReconnect checks whether a session is configured for automatic reconnection.
func (m *Manager) shouldAutoReconnect(sess *Session) bool {
if sess.profile == nil {
return false
}
switch sess.profile.Protocol {
case models.ProtocolSSH:
if sess.profile.SSH != nil {
return sess.profile.SSH.AutoReconnect
}
case models.ProtocolTelnet:
if sess.profile.Telnet != nil {
return sess.profile.Telnet.AutoReconnect
}
case models.ProtocolRAW:
if sess.profile.RAW != nil {
return sess.profile.RAW.AutoReconnect
}
}
return false
}
// maxReconnectAttempts returns the configured (or default) max retry count.
func (m *Manager) maxReconnectAttempts(sess *Session) int {
maxTries := 5
switch sess.profile.Protocol {
case models.ProtocolSSH:
if sess.profile.SSH != nil && sess.profile.SSH.MaxReconnectTries > 0 {
maxTries = sess.profile.SSH.MaxReconnectTries
}
case models.ProtocolTelnet:
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
maxTries = sess.profile.Telnet.MaxReconnectTries
}
}
return maxTries
}
// scheduleReconnect initiates an exponential-backoff reconnection loop for a session.
func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
maxTries := m.maxReconnectAttempts(sess)
m.mu.Lock()
sess.reconnectCount++
attempt := sess.reconnectCount
if attempt > maxTries {
// Give up — too many attempts.
sess.Info.State = models.SessionDisconnected
m.mu.Unlock()
m.emitEvent("session:closed", map[string]interface{}{
"sessionID": sessionID,
})
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionDisconnected),
"error": "auto-reconnect failed after maximum attempts",
})
return
}
sess.Info.State = models.SessionReconnecting
sess.Info.Error = ""
// Cancel any previous reconnect timer.
if sess.cancelReconn != nil {
sess.cancelReconn()
}
ctx, cancel := context.WithCancel(context.Background())
sess.cancelReconn = cancel
m.mu.Unlock()
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s.
delaySec := 1 << (attempt - 1) // 2^(attempt-1)
if delaySec > 30 {
delaySec = 30
}
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionReconnecting),
"attempt": attempt,
"maxRetries": maxTries,
"delaySec": delaySec,
})
go func() {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(delaySec) * time.Second):
}
// Clear old clients before reconnecting.
m.mu.Lock()
sess.sshClient = nil
sess.telnetClient = nil
sess.rawClient = nil
sess.sftpClient = nil
sess.Info.State = models.SessionConnecting
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnecting),
})
var err error
switch sess.profile.Protocol {
case models.ProtocolSSH:
err = m.connectSSH(sess, m.buildInteractiveSecretCallback(sessionID))
case models.ProtocolTelnet:
err = m.connectTelnet(sess)
case models.ProtocolRAW:
err = m.connectRAW(sess)
default:
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
}
if err != nil {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
// Schedule the next attempt.
m.scheduleReconnect(sess, sessionID)
return
}
// Success — reset the counter.
m.mu.Lock()
sess.reconnectCount = 0
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
}()
}
// buildInteractiveSecretCallback creates a getSecret callback that prompts
// the user via Wails events. Used during auto-reconnect flows where the
// original callback is no longer available.
func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) {
return func(prompt string) (string, error) {
// Use the same event-based flow as the initial connection.
requestID := uuid.New().String()
ch := make(chan string, 1)
m.mu.Lock()
// Store a temporary secret channel keyed by requestID.
if m.ctx != nil {
wailsRuntime.EventsEmit(m.ctx, "session:secret:prompt", map[string]interface{}{
"requestID": requestID,
"sessionID": sessionID,
"title": "Reconnection Credentials",
"message": prompt,
})
}
m.mu.Unlock()
// Wait for the response with a timeout.
var response string
cancel := make(chan struct{})
if m.ctx != nil {
wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
data, ok := optionalData[0].(map[string]interface{})
if !ok {
return
}
if rid, _ := data["requestID"].(string); rid == requestID {
val, _ := data["value"].(string)
select {
case ch <- val:
default:
}
}
})
}
select {
case response = <-ch:
case <-time.After(60 * time.Second):
close(cancel)
return "", fmt.Errorf("credential prompt timed out during reconnect")
}
return response, nil
}
}
// CancelReconnect cancels any in-progress auto-reconnect for a session.
func (m *Manager) CancelReconnect(sessionID string) {
m.mu.Lock()
sess, ok := m.sessions[sessionID]
if ok && sess.cancelReconn != nil {
sess.cancelReconn()
sess.cancelReconn = nil
sess.reconnectCount = 0
sess.Info.State = models.SessionDisconnected
}
m.mu.Unlock()
if ok {
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionDisconnected),
})
}
}
// createTabName generates a default tab name from the profile's display name // createTabName generates a default tab name from the profile's display name
// and host. // and host.
func (m *Manager) createTabName(profile *models.Profile) string { func (m *Manager) createTabName(profile *models.Profile) string {