From b652350bda31d592f714a077d0d85f66b40e8e02 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 16 Jan 2026 00:29:25 +0000 Subject: [PATCH] update extension and proxy for keepalive --- browser-ext/background/background.js | 282 ++++++++++++++++++++++++--- browser-ext/content/content.js | 29 +-- browser-ext/popup/popup.js | 16 +- browser-ext/proxy/proxy.js | 48 ++++- 4 files changed, 317 insertions(+), 58 deletions(-) diff --git a/browser-ext/background/background.js b/browser-ext/background/background.js index 102159ce4..1471a340c 100644 --- a/browser-ext/background/background.js +++ b/browser-ext/background/background.js @@ -3,24 +3,187 @@ * * Service worker that manages WebSocket connection to the proxy * and handles DAP protocol communication. + * + * NOTE: Chrome MV3 service workers can be terminated after ~30s of inactivity. + * We handle this with: + * 1. Keepalive pings to keep the WebSocket active + * 2. Automatic reconnection when the service worker restarts + * 3. Storing connection state in chrome.storage.session */ // Connection state let ws = null; -let connectionStatus = 'disconnected'; // disconnected, connecting, connected, paused, running +let connectionStatus = 'disconnected'; // disconnected, connecting, connected, paused, running, error let sequenceNumber = 1; -const pendingRequests = new Map(); // seq -> { resolve, reject, command } +const pendingRequests = new Map(); // seq -> { resolve, reject, command, timeout } + +// Reconnection state +let reconnectAttempts = 0; +const MAX_RECONNECT_ATTEMPTS = 10; +const RECONNECT_BASE_DELAY = 1000; // Start with 1s, exponential backoff +let reconnectTimer = null; +let lastConnectedUrl = null; +let wasConnectedBeforeIdle = false; + +// Keepalive interval - send ping every 15s to keep service worker AND WebSocket alive +// Chrome MV3 service workers get suspended after ~30s of inactivity +// We need to send actual WebSocket messages to keep both alive +const KEEPALIVE_INTERVAL = 15000; +let keepaliveTimer = null; // Default configuration const DEFAULT_URL = 'ws://localhost:4712'; +/** + * Initialize on service worker startup - check if we should reconnect + */ +async function initializeOnStartup() { + console.log('[Background] Service worker starting up...'); + + try { + // Restore state from session storage + const data = await chrome.storage.session.get(['connectionUrl', 'shouldBeConnected', 'lastStatus']); + + if (data.shouldBeConnected && data.connectionUrl) { + console.log('[Background] Restoring connection after service worker restart'); + lastConnectedUrl = data.connectionUrl; + wasConnectedBeforeIdle = true; + + // Small delay to let things settle + setTimeout(() => { + connect(data.connectionUrl); + }, 500); + } + } catch (e) { + console.log('[Background] No session state to restore'); + } +} + +/** + * Save connection state to session storage (survives service worker restart) + */ +async function saveConnectionState() { + try { + await chrome.storage.session.set({ + connectionUrl: lastConnectedUrl, + shouldBeConnected: connectionStatus !== 'disconnected' && connectionStatus !== 'error', + lastStatus: connectionStatus, + }); + } catch (e) { + console.warn('[Background] Failed to save connection state:', e); + } +} + +/** + * Clear connection state from session storage + */ +async function clearConnectionState() { + try { + await chrome.storage.session.remove(['connectionUrl', 'shouldBeConnected', 'lastStatus']); + } catch (e) { + console.warn('[Background] Failed to clear connection state:', e); + } +} + +/** + * Start keepalive ping to prevent service worker termination + * CRITICAL: We must send actual WebSocket messages to keep the connection alive. + * Just having a timer is not enough - Chrome will suspend the service worker + * and close the WebSocket with code 1001 after ~30s of inactivity. + */ +function startKeepalive() { + stopKeepalive(); + + keepaliveTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { + // Send a lightweight keepalive message over WebSocket + // This does two things: + // 1. Keeps the WebSocket connection active (prevents proxy timeout) + // 2. Creates activity that keeps the Chrome service worker alive + const keepaliveMsg = JSON.stringify({ type: 'keepalive', timestamp: Date.now() }); + ws.send(keepaliveMsg); + console.log('[Background] Keepalive sent'); + } catch (e) { + console.error('[Background] Keepalive error:', e); + handleUnexpectedClose(); + } + } else if (wasConnectedBeforeIdle || lastConnectedUrl) { + // Connection was lost, try to reconnect + console.log('[Background] Connection lost during keepalive check'); + handleUnexpectedClose(); + } + }, KEEPALIVE_INTERVAL); + + console.log('[Background] Keepalive timer started (interval: ' + KEEPALIVE_INTERVAL + 'ms)'); +} + +/** + * Stop keepalive ping + */ +function stopKeepalive() { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + console.log('[Background] Keepalive timer stopped'); + } +} + +/** + * Handle unexpected connection close - attempt reconnection + */ +function handleUnexpectedClose() { + if (reconnectTimer) { + return; // Already trying to reconnect + } + + if (!lastConnectedUrl) { + console.log('[Background] No URL to reconnect to'); + return; + } + + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.error('[Background] Max reconnection attempts reached'); + connectionStatus = 'error'; + broadcastStatus(); + clearConnectionState(); + return; + } + + const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts), 30000); + reconnectAttempts++; + + console.log(`[Background] Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms`); + connectionStatus = 'connecting'; + broadcastStatus(); + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (connectionStatus !== 'connected' && connectionStatus !== 'paused' && connectionStatus !== 'running') { + connect(lastConnectedUrl); + } + }, delay); +} + /** * Connect to the WebSocket proxy */ function connect(url) { - if (ws && ws.readyState === WebSocket.OPEN) { - console.log('[Background] Already connected'); - return; + // Clean up existing connection + if (ws) { + try { + ws.onclose = null; // Prevent triggering reconnect + ws.close(1000, 'Reconnecting'); + } catch (e) { + // Ignore + } + ws = null; + } + + // Clear any pending reconnect + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; } connectionStatus = 'connecting'; @@ -28,22 +191,35 @@ function connect(url) { // Use provided URL or default const wsUrl = url || DEFAULT_URL; + lastConnectedUrl = wsUrl; console.log(`[Background] Connecting to ${wsUrl}`); - ws = new WebSocket(wsUrl); + try { + ws = new WebSocket(wsUrl); + } catch (e) { + console.error('[Background] Failed to create WebSocket:', e); + connectionStatus = 'error'; + broadcastStatus(); + handleUnexpectedClose(); + return; + } ws.onopen = async () => { console.log('[Background] WebSocket connected'); connectionStatus = 'connected'; + reconnectAttempts = 0; // Reset on successful connection + wasConnectedBeforeIdle = true; broadcastStatus(); + saveConnectionState(); + startKeepalive(); // Initialize DAP session try { await initializeDapSession(); } catch (error) { console.error('[Background] Failed to initialize DAP session:', error); - connectionStatus = 'error'; - broadcastStatus(); + // Don't set error status - the connection might still be usable + // The DAP server might just need the job to progress } }; @@ -57,22 +233,40 @@ function connect(url) { }; ws.onclose = (event) => { - console.log(`[Background] WebSocket closed: ${event.code} ${event.reason}`); + console.log(`[Background] WebSocket closed: ${event.code} ${event.reason || '(no reason)'}`); ws = null; - connectionStatus = 'disconnected'; - broadcastStatus(); + stopKeepalive(); // Reject any pending requests for (const [seq, pending] of pendingRequests) { + if (pending.timeout) clearTimeout(pending.timeout); pending.reject(new Error('Connection closed')); } pendingRequests.clear(); + + // Determine if we should reconnect + // Code 1000 = normal closure (user initiated) + // Code 1001 = going away (service worker idle, browser closing, etc.) + // Code 1006 = abnormal closure (connection lost) + // Code 1011 = server error + const shouldReconnect = event.code !== 1000; + + if (shouldReconnect && wasConnectedBeforeIdle) { + console.log('[Background] Unexpected close, will attempt reconnect'); + connectionStatus = 'connecting'; + broadcastStatus(); + handleUnexpectedClose(); + } else { + connectionStatus = 'disconnected'; + wasConnectedBeforeIdle = false; + broadcastStatus(); + clearConnectionState(); + } }; ws.onerror = (event) => { console.error('[Background] WebSocket error:', event); - connectionStatus = 'error'; - broadcastStatus(); + // onclose will be called after onerror, so we handle reconnection there }; } @@ -80,14 +274,34 @@ function connect(url) { * Disconnect from the WebSocket proxy */ function disconnect() { + // Stop any reconnection attempts + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectAttempts = 0; + wasConnectedBeforeIdle = false; + stopKeepalive(); + if (ws) { // Send disconnect request to DAP server first sendDapRequest('disconnect', {}).catch(() => {}); - ws.close(1000, 'User disconnected'); + + // Prevent reconnection on this close + const socket = ws; ws = null; + socket.onclose = null; + + try { + socket.close(1000, 'User disconnected'); + } catch (e) { + // Ignore + } } + connectionStatus = 'disconnected'; broadcastStatus(); + clearConnectionState(); } /** @@ -139,17 +353,24 @@ function sendDapRequest(command, args = {}) { }; console.log(`[Background] Sending DAP request: ${command} (seq: ${seq})`); - pendingRequests.set(seq, { resolve, reject, command }); // Set timeout for request - setTimeout(() => { + const timeout = setTimeout(() => { if (pendingRequests.has(seq)) { pendingRequests.delete(seq); reject(new Error(`Request timed out: ${command}`)); } }, 30000); - ws.send(JSON.stringify(request)); + pendingRequests.set(seq, { resolve, reject, command, timeout }); + + try { + ws.send(JSON.stringify(request)); + } catch (e) { + pendingRequests.delete(seq); + clearTimeout(timeout); + reject(new Error(`Failed to send request: ${e.message}`)); + } }); } @@ -163,8 +384,10 @@ function handleDapMessage(message) { handleDapEvent(message); } else if (message.type === 'proxy-error') { console.error('[Background] Proxy error:', message.message); - connectionStatus = 'error'; - broadcastStatus(); + // Don't immediately set error status - might be transient + } else if (message.type === 'keepalive-ack') { + // Keepalive acknowledged by proxy - connection is healthy + console.log('[Background] Keepalive acknowledged'); } } @@ -179,6 +402,7 @@ function handleDapResponse(response) { } pendingRequests.delete(response.request_seq); + if (pending.timeout) clearTimeout(pending.timeout); if (response.success) { console.log(`[Background] DAP response success: ${response.command}`); @@ -203,16 +427,20 @@ function handleDapEvent(event) { case 'stopped': connectionStatus = 'paused'; broadcastStatus(); + saveConnectionState(); break; case 'continued': connectionStatus = 'running'; broadcastStatus(); + saveConnectionState(); break; case 'terminated': connectionStatus = 'disconnected'; + wasConnectedBeforeIdle = false; broadcastStatus(); + clearConnectionState(); break; case 'output': @@ -228,13 +456,16 @@ function handleDapEvent(event) { * Broadcast connection status to popup and content scripts */ function broadcastStatus() { - // Broadcast to all extension contexts - chrome.runtime.sendMessage({ type: 'status-changed', status: connectionStatus }).catch(() => {}); + const statusMessage = { type: 'status-changed', status: connectionStatus }; + + // Broadcast to all extension contexts (popup) + chrome.runtime.sendMessage(statusMessage).catch(() => {}); // Broadcast to content scripts chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => { + if (chrome.runtime.lastError) return; tabs.forEach((tab) => { - chrome.tabs.sendMessage(tab.id, { type: 'status-changed', status: connectionStatus }).catch(() => {}); + chrome.tabs.sendMessage(tab.id, statusMessage).catch(() => {}); }); }); } @@ -244,6 +475,7 @@ function broadcastStatus() { */ function broadcastEvent(event) { chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => { + if (chrome.runtime.lastError) return; tabs.forEach((tab) => { chrome.tabs.sendMessage(tab.id, { type: 'dap-event', event }).catch(() => {}); }); @@ -258,10 +490,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { switch (message.type) { case 'get-status': - sendResponse({ status: connectionStatus }); + sendResponse({ status: connectionStatus, reconnecting: reconnectTimer !== null }); return false; case 'connect': + reconnectAttempts = 0; // Reset attempts on manual connect connect(message.url || DEFAULT_URL); sendResponse({ status: connectionStatus }); return false; @@ -288,5 +521,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } }); +// Initialize on startup +initializeOnStartup(); + // Log startup console.log('[Background] Actions DAP Debugger background script loaded'); diff --git a/browser-ext/content/content.js b/browser-ext/content/content.js index 51ff48557..ec04acd0b 100644 --- a/browser-ext/content/content.js +++ b/browser-ext/content/content.js @@ -279,30 +279,17 @@ function appendOutput(text, type) { const output = document.querySelector('.dap-repl-output'); if (!output) return; - const line = document.createElement('div'); - line.className = `dap-output-${type}`; - if (type === 'error') line.classList.add('color-fg-danger'); - if (type === 'input') line.classList.add('color-fg-muted'); - - // Handle multi-line output + // Handle multi-line output - each line gets its own div const lines = text.split('\n'); - lines.forEach((l, i) => { - if (i > 0) { - output.appendChild(document.createElement('br')); - } - const span = document.createElement('span'); - span.textContent = l; - if (i === 0) { - span.className = line.className; - } - output.appendChild(span); + lines.forEach((l) => { + const div = document.createElement('div'); + div.className = `dap-output-${type}`; + if (type === 'error') div.classList.add('color-fg-danger'); + if (type === 'input') div.classList.add('color-fg-muted'); + div.textContent = l; + output.appendChild(div); }); - if (lines.length === 1) { - line.textContent = text; - output.appendChild(line); - } - output.scrollTop = output.scrollHeight; } diff --git a/browser-ext/popup/popup.js b/browser-ext/popup/popup.js index 9db74a737..3a27d33e5 100644 --- a/browser-ext/popup/popup.js +++ b/browser-ext/popup/popup.js @@ -18,15 +18,15 @@ document.addEventListener('DOMContentLoaded', () => { // Get current status from background chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { - if (response && response.status) { - updateStatusUI(response.status); + if (response) { + updateStatusUI(response.status, response.reconnecting); } }); // Listen for status changes chrome.runtime.onMessage.addListener((message) => { if (message.type === 'status-changed') { - updateStatusUI(message.status); + updateStatusUI(message.status, message.reconnecting); } }); @@ -60,15 +60,15 @@ document.addEventListener('DOMContentLoaded', () => { /** * Update the UI to reflect current status */ - function updateStatusUI(status) { + function updateStatusUI(status, reconnecting = false) { // Update text const statusNames = { disconnected: 'Disconnected', - connecting: 'Connecting...', + connecting: reconnecting ? 'Reconnecting...' : 'Connecting...', connected: 'Connected', paused: 'Paused', running: 'Running', - error: 'Error', + error: 'Connection Error', }; statusText.textContent = statusNames[status] || status; @@ -80,11 +80,11 @@ document.addEventListener('DOMContentLoaded', () => { const isConnecting = status === 'connecting'; connectBtn.disabled = isConnected || isConnecting; - disconnectBtn.disabled = !isConnected; + disconnectBtn.disabled = status === 'disconnected'; // Update connect button text if (isConnecting) { - connectBtn.textContent = 'Connecting...'; + connectBtn.textContent = reconnecting ? 'Reconnecting...' : 'Connecting...'; } else { connectBtn.textContent = 'Connect'; } diff --git a/browser-ext/proxy/proxy.js b/browser-ext/proxy/proxy.js index f767ad165..e31c5f98b 100644 --- a/browser-ext/proxy/proxy.js +++ b/browser-ext/proxy/proxy.js @@ -36,14 +36,38 @@ console.log(`[Proxy] Starting WebSocket-to-TCP proxy`); console.log(`[Proxy] WebSocket: ws://localhost:${config.wsPort}`); console.log(`[Proxy] DAP Server: tcp://${config.dapHost}:${config.dapPort}`); -const wss = new WebSocket.Server({ port: config.wsPort }); +const wss = new WebSocket.Server({ + port: config.wsPort, + // Enable ping/pong for connection health checks + clientTracking: true, +}); console.log(`[Proxy] WebSocket server listening on port ${config.wsPort}`); +// Ping all clients every 25 seconds to detect dead connections +// This is shorter than Chrome's service worker timeout (~30s) +const PING_INTERVAL = 25000; +const pingInterval = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + console.log(`[Proxy] Client failed to respond to ping, terminating`); + return ws.terminate(); + } + ws.isAlive = false; + ws.ping(); + }); +}, PING_INTERVAL); + wss.on('connection', (ws, req) => { const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`; console.log(`[Proxy] WebSocket client connected: ${clientId}`); + // Mark as alive for ping/pong tracking + ws.isAlive = true; + ws.on('pong', () => { + ws.isAlive = true; + }); + // Connect to DAP TCP server const tcp = net.createConnection({ host: config.dapHost, @@ -80,15 +104,26 @@ wss.on('connection', (ws, req) => { // WebSocket → TCP: Add Content-Length framing ws.on('message', (data) => { - if (!tcpConnected) { - console.warn(`[Proxy] TCP not connected, dropping message`); - return; - } - const json = data.toString(); try { // Validate it's valid JSON const parsed = JSON.parse(json); + + // Handle keepalive messages from the browser extension - don't forward to DAP server + if (parsed.type === 'keepalive') { + console.log(`[Proxy] Keepalive received from client`); + // Respond with a keepalive-ack to confirm the connection is alive + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'keepalive-ack', timestamp: Date.now() })); + } + return; + } + + if (!tcpConnected) { + console.warn(`[Proxy] TCP not connected, dropping message`); + return; + } + console.log(`[Proxy] WS→TCP: ${parsed.command || parsed.event || 'message'}`); // Add DAP framing @@ -163,6 +198,7 @@ wss.on('error', (err) => { // Graceful shutdown process.on('SIGINT', () => { console.log(`\n[Proxy] Shutting down...`); + clearInterval(pingInterval); wss.clients.forEach((ws) => ws.close(1001, 'Server shutting down')); wss.close(() => { console.log(`[Proxy] Goodbye!`);