refactor(terminal): enhance terminal connection management and error handling, including improved reconnection logic and cleanup procedures

This commit is contained in:
Andras Bacsai
2025-05-29 14:36:13 +02:00
parent 261a2fe564
commit 05a03c44d3
3 changed files with 257 additions and 83 deletions

View File

@@ -43,7 +43,10 @@ class Terminal extends Component
#[On('send-terminal-command')] #[On('send-terminal-command')]
public function sendTerminalCommand($isContainer, $identifier, $serverUuid) public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
{ {
$server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->where('settings.is_terminal_enabled', true)->firstOrFail(); $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail();
if (! $server->isTerminalEnabled()) {
throw new \RuntimeException('Terminal access is disabled on this server.');
}
if ($isContainer) { if ($isContainer) {
// Validate container identifier format (alphanumeric, dashes, and underscores only) // Validate container identifier format (alphanumeric, dashes, and underscores only)

View File

@@ -17,16 +17,32 @@ export function initializeTerminalComponent() {
MAX_PENDING_WRITES: 5, MAX_PENDING_WRITES: 5,
keepAliveInterval: null, keepAliveInterval: null,
reconnectInterval: null, reconnectInterval: null,
// Enhanced connection management
connectionState: 'disconnected', // 'connecting', 'connected', 'disconnected', 'reconnecting'
reconnectAttempts: 0,
maxReconnectAttempts: 10,
baseReconnectDelay: 1000,
maxReconnectDelay: 30000,
connectionTimeout: 10000,
connectionTimeoutId: null,
lastPingTime: null,
pingTimeout: 35000, // 5 seconds longer than ping interval
pingTimeoutId: null,
heartbeatMissed: 0,
maxHeartbeatMisses: 3,
init() { init() {
this.setupTerminal(); this.setupTerminal();
// Add a small delay for initial connection to ensure everything is ready
setTimeout(() => {
this.initializeWebSocket(); this.initializeWebSocket();
}, 100);
this.setupTerminalEventListeners(); this.setupTerminalEventListeners();
this.$wire.on('send-back-command', (command) => { this.$wire.on('send-back-command', (command) => {
this.socket.send(JSON.stringify({ this.sendMessage({ command: command });
command: command
}));
}); });
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
@@ -47,19 +63,33 @@ export function initializeTerminalComponent() {
['livewire:navigated', 'beforeunload'].forEach((event) => { ['livewire:navigated', 'beforeunload'].forEach((event) => {
document.addEventListener(event, () => { document.addEventListener(event, () => {
this.checkIfProcessIsRunningAndKillIt(); this.cleanup();
clearInterval(this.keepAliveInterval);
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
}
}, { once: true }); }, { once: true });
}); });
window.onresize = () => { window.onresize = () => {
this.resizeTerminal() this.resizeTerminal()
}; };
}, },
cleanup() {
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.connectionState = 'disconnected';
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
},
clearAllTimers() {
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId]
.forEach(timer => timer && clearInterval(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
},
resetTerminal() { resetTerminal() {
if (this.term) { if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost.'); this.$wire.dispatch('error', 'Terminal websocket connection lost.');
@@ -76,6 +106,7 @@ export function initializeTerminalComponent() {
}); });
} }
}, },
setupTerminal() { setupTerminal() {
const terminalElement = document.getElementById('terminal'); const terminalElement = document.getElementById('terminal');
if (terminalElement) { if (terminalElement) {
@@ -97,7 +128,20 @@ export function initializeTerminalComponent() {
}, },
initializeWebSocket() { initializeWebSocket() {
if (!this.socket || this.socket.readyState === WebSocket.CLOSED) { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
console.log('[Terminal] WebSocket already connecting/connected, skipping');
return; // Already connecting or connected
}
this.connectionState = 'connecting';
this.clearAllTimers();
// Ensure terminal config is available
if (!window.terminalConfig) {
console.warn('[Terminal] Terminal config not available, using defaults');
window.terminalConfig = {};
}
const predefined = window.terminalConfig const predefined = window.terminalConfig
const connectionString = { const connectionString = {
protocol: window.location.protocol === 'https:' ? 'wss' : 'ws', protocol: window.location.protocol === 'https:' ? 'wss' : 'ws',
@@ -105,6 +149,7 @@ export function initializeTerminalComponent() {
port: ":6002", port: ":6002",
path: '/terminal/ws' path: '/terminal/ws'
} }
if (!window.location.port) { if (!window.location.port) {
connectionString.port = '' connectionString.port = ''
} }
@@ -118,45 +163,129 @@ export function initializeTerminalComponent() {
connectionString.protocol = predefined.protocol connectionString.protocol = predefined.protocol
} }
const url = const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
`${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` console.log(`[Terminal] Attempting connection to: ${url}`);
try {
this.socket = new WebSocket(url); this.socket = new WebSocket(url);
this.socket.onopen = () => { // Set connection timeout - increased for initial connection
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
}; this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') {
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`);
this.socket.close();
this.handleConnectionError('Connection timeout');
}
}, timeoutMs);
this.socket.onopen = this.handleSocketOpen.bind(this);
this.socket.onmessage = this.handleSocketMessage.bind(this); this.socket.onmessage = this.handleSocketMessage.bind(this);
this.socket.onerror = (e) => { this.socket.onerror = this.handleSocketError.bind(this);
console.error('[Terminal] WebSocket error.'); this.socket.onclose = this.handleSocketClose.bind(this);
};
this.socket.onclose = () => { } catch (error) {
console.warn('[Terminal] WebSocket connection closed.'); console.error('[Terminal] Failed to create WebSocket:', error);
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
}
},
handleSocketOpen() {
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');
this.connectionState = 'connected';
this.reconnectAttempts = 0;
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
// Clear connection timeout
if (this.connectionTimeoutId) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = null;
}
// Start ping timeout monitoring
this.resetPingTimeout();
},
handleSocketError(error) {
console.error('[Terminal] WebSocket error:', error);
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred');
},
handleSocketClose(event) {
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
console.log('[Terminal] Was clean close:', event.code === 1000);
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.connectionState = 'disconnected';
this.clearAllTimers();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
// Don't show terminal reset message on first connection attempt
if (this.reconnectAttempts > 0) {
this.resetTerminal(); this.resetTerminal();
this.message = '(connection closed)'; this.message = '(connection closed)';
this.terminalActive = false; this.terminalActive = false;
this.reconnect(); }
}; this.scheduleReconnect();
} }
}, },
reconnect() { handleConnectionError(reason) {
if (this.reconnectInterval) { console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
clearInterval(this.reconnectInterval); this.connectionState = 'disconnected';
}
this.reconnectInterval = setInterval(() => {
console.warn('[Terminal] Attempting to reconnect...');
this.initializeWebSocket();
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
console.log('[Terminal] Reconnected successfully');
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
if (this.reconnectAttempts >= 2) {
this.$wire.dispatch('error', `Terminal connection error: ${reason}`);
}
this.scheduleReconnect();
},
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)';
return;
}
this.connectionState = 'reconnecting';
// Exponential backoff with jitter
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
this.maxReconnectDelay
);
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => {
this.reconnectAttempts++;
this.initializeWebSocket();
}, delay);
},
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
console.warn('[Terminal] WebSocket not ready, message not sent:', message);
} }
}, 2000);
}, },
handleSocketMessage(event) { handleSocketMessage(event) {
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
this.resetPingTimeout();
return;
}
if (event.data === 'pty-ready') { if (event.data === 'pty-ready') {
if (!this.term._initialized) { if (!this.term._initialized) {
this.term.open(document.getElementById('terminal')); this.term.open(document.getElementById('terminal'));
@@ -187,20 +316,22 @@ export function initializeTerminalComponent() {
}); });
} catch (error) { } catch (error) {
console.error('[Terminal] Write operation failed:', error); console.error('[Terminal] Write operation failed:', error);
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
} }
} }
}, },
flowControlCallback() { flowControlCallback() {
this.pendingWrites--; this.pendingWrites = Math.max(0, this.pendingWrites - 1);
if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) { if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) {
this.paused = true; this.paused = true;
this.socket.send(JSON.stringify({ pause: true })); this.sendMessage({ pause: true });
return; return;
} }
if (this.pendingWrites <= this.MAX_PENDING_WRITES && this.paused) { if (this.pendingWrites <= Math.floor(this.MAX_PENDING_WRITES / 2) && this.paused) {
this.paused = false; this.paused = false;
this.socket.send(JSON.stringify({ resume: true })); this.sendMessage({ resume: true });
return; return;
} }
}, },
@@ -209,16 +340,12 @@ export function initializeTerminalComponent() {
if (!this.term) return; if (!this.term) return;
this.term.onData((data) => { this.term.onData((data) => {
if (this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ message: data });
this.socket.send(JSON.stringify({ message: data }));
if (data === '\r') { if (data === '\r') {
this.commandBuffer = ''; this.commandBuffer = '';
} else { } else {
this.commandBuffer += data; this.commandBuffer += data;
} }
} else {
console.warn('[Terminal] WebSocket not ready, data not sent');
}
}); });
// Copy and paste functionality // Copy and paste functionality
@@ -240,14 +367,31 @@ export function initializeTerminalComponent() {
keepAlive() { keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ ping: true })); this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
// Attempt to reconnect if we're disconnected
this.initializeWebSocket();
} }
}, },
checkIfProcessIsRunningAndKillIt() { resetPingTimeout() {
if (this.socket && this.socket.readyState == WebSocket.OPEN) { if (this.pingTimeoutId) {
this.socket.send(JSON.stringify({ checkActive: 'force' })); clearTimeout(this.pingTimeoutId);
} }
this.pingTimeoutId = setTimeout(() => {
this.heartbeatMissed++;
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
console.error('[Terminal] Too many missed heartbeats, closing connection');
this.socket.close(1001, 'Heartbeat timeout');
}
}, this.pingTimeout);
},
checkIfProcessIsRunningAndKillIt() {
this.sendMessage({ checkActive: 'force' });
}, },
makeFullscreen() { makeFullscreen() {
@@ -260,18 +404,44 @@ export function initializeTerminalComponent() {
resizeTerminal() { resizeTerminal() {
if (!this.terminalActive || !this.term || !this.fitAddon) return; if (!this.terminalActive || !this.term || !this.fitAddon) return;
try {
this.fitAddon.fit(); this.fitAddon.fit();
const height = this.$refs.terminalWrapper.clientHeight; const height = this.$refs.terminalWrapper.clientHeight;
const width = this.$refs.terminalWrapper.clientWidth; const width = this.$refs.terminalWrapper.clientWidth;
const rows = Math.floor(height / this.term._core._renderService._charSizeService.height) - 1; const charSize = this.term._core._renderService._charSizeService;
const cols = Math.floor(width / this.term._core._renderService._charSizeService.width) - 1;
const termWidth = cols; if (!charSize.height || !charSize.width) {
const termHeight = rows; // Fallback values if char size not available yet
this.term.resize(termWidth, termHeight); setTimeout(() => this.resizeTerminal(), 100);
this.socket.send(JSON.stringify({ return;
resize: { cols: termWidth, rows: termHeight } }
}));
const rows = Math.floor(height / charSize.height) - 1;
const cols = Math.floor(width / charSize.width) - 1;
if (rows > 0 && cols > 0) {
this.term.resize(cols, rows);
this.sendMessage({
resize: { cols: cols, rows: rows }
});
}
} catch (error) {
console.error('[Terminal] Resize error:', error);
}
}, },
// Utility method to get connection status for debugging
getConnectionStatus() {
return {
state: this.connectionState,
readyState: this.socket ? this.socket.readyState : 'No socket',
reconnectAttempts: this.reconnectAttempts,
pendingWrites: this.pendingWrites,
paused: this.paused,
lastPingTime: this.lastPingTime,
heartbeatMissed: this.heartbeatMissed
};
}
}; };
} }

View File

@@ -36,6 +36,7 @@
@if (auth()->user()->isAdmin()) @if (auth()->user()->isAdmin())
<div wire:key="terminal-access-change-{{ $isTerminalEnabled }}" class="pb-4"> <div wire:key="terminal-access-change-{{ $isTerminalEnabled }}" class="pb-4">
<x-modal-confirmation title="Confirm Terminal Access Change?" <x-modal-confirmation title="Confirm Terminal Access Change?"
temporaryDisableTwoStepConfirmation
buttonTitle="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}" buttonTitle="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}"
submitAction="toggleTerminal" :actions="[ submitAction="toggleTerminal" :actions="[
$isTerminalEnabled $isTerminalEnabled