diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 61208eb..4106c35 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -430,6 +430,7 @@ processManager = new ProcessManager({ elements: collectProcessManagerElements(), getLanguage: () => currentLanguage, onError: (message) => event(message, 'process', true), + onReconnect: (zh, en) => event(bilingual(zh, en), 'process'), }); function terminalTheme(): Record { diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts index 9def423..13f189e 100644 --- a/frontend/src/process-manager.ts +++ b/frontend/src/process-manager.ts @@ -52,9 +52,14 @@ interface ProcessManagerOptions { elements: ProcessManagerElements; getLanguage: () => 'zh-CN' | 'en'; onError: (message: string) => void; + onReconnect?: (zh: string, en: string) => void; } const MAX_PROCESSES = 512; +// Auto-reconnect backoff for the process monitor after an unexpected drop. +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 15_000; +const RECONNECT_MAX_ATTEMPTS = 10; function finitePercent(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 10_000; @@ -135,17 +140,25 @@ export class ProcessManager { private readonly elements: ProcessManagerElements; private readonly getLanguage: () => 'zh-CN' | 'en'; private readonly onError: (message: string) => void; + private readonly onReconnect: ((zh: string, en: string) => void) | undefined; private socket: WebSocket | null = null; private generation = 0; private snapshot: ProcessSnapshot | null = null; private sortKey: ProcessSortKey = 'cpuPercent'; private sortDirection: SortDirection = 'descending'; private readonly progressAnimations = new Map(); + // Whether the process monitor should stay connected for the current SSH session. Set false + // when the user tears the session down (reset) so an unexpected drop is not mistaken for one. + private wantConnection = false; + private url: string | null = null; + private reconnectTimer: number | null = null; + private reconnectAttempts = 0; constructor(options: ProcessManagerOptions) { this.elements = options.elements; this.getLanguage = options.getLanguage; this.onError = options.onError; + this.onReconnect = options.onReconnect; this.elements.panel.addEventListener('click', (event) => { const button = (event.target as HTMLElement).closest('[data-process-sort]'); if (!button) return; @@ -155,6 +168,8 @@ export class ProcessManager { } attach(url: string): void { + this.wantConnection = true; + this.url = url; this.resetSocket(); const target = new URL(url, window.location.href); if (target.origin !== window.location.origin || target.pathname !== '/api/processes') { @@ -167,6 +182,7 @@ export class ProcessManager { this.setStatus('正在启动进程监控…', 'Starting process monitor…'); socket.addEventListener('open', () => { if (!this.isCurrent(socket, generation)) return; + this.reconnectAttempts = 0; socket.send(JSON.stringify({ type: 'process_start' })); }); socket.addEventListener('message', (event) => { @@ -181,12 +197,19 @@ export class ProcessManager { if (!this.isCurrent(socket, generation)) return; this.socket = null; if (event.code !== 1000 && event.code !== 1005) { - this.showError('进程监控已意外停止。', 'Process monitor stopped unexpectedly.'); + // Unexpected drop. Auto-reconnect unless the user intentionally tore the session down. + if (this.wantConnection) { + this.scheduleReconnect(); + } else { + this.showError('进程监控已意外停止。', 'Process monitor stopped unexpectedly.'); + } } }); } reset(): void { + this.wantConnection = false; + this.clearReconnectTimer(); this.resetSocket(); this.snapshot = null; this.render(); @@ -409,6 +432,37 @@ export class ProcessManager { if (socket.readyState < WebSocket.CLOSING) socket.close(1000, 'Process monitor reset'); } + private scheduleReconnect(): void { + if (!this.wantConnection || !this.url || this.reconnectTimer !== null) return; + this.reconnectAttempts++; + if (this.reconnectAttempts > RECONNECT_MAX_ATTEMPTS) { + this.reconnectAttempts = 0; + this.showError('进程监控已意外停止。', 'Process monitor stopped unexpectedly.'); + return; + } + const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * (2 ** (this.reconnectAttempts - 1))); + if (this.reconnectAttempts === 1 && this.onReconnect) { + this.onReconnect('进程监控已意外停止,正在尝试自动重连…', 'Process monitor stopped unexpectedly; attempting to reconnect automatically…'); + } + this.setStatus('进程监控连接已断开,正在重连…', 'Process monitor disconnected; reconnecting…'); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + if (!this.wantConnection || !this.url) return; + try { + this.attach(this.url); + } catch { + this.scheduleReconnect(); + } + }, delay); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + private isCurrent(socket: WebSocket, generation: number): boolean { return this.socket === socket && this.generation === generation; } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index e61c73d..457414d 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -283,12 +283,13 @@ export class SSHSessionDO implements DurableObject { return Response.json({ error: 'Invalid process attachment token' }, { status: 401 }); } const attach = this.processAttachTokens.get(token); - if (!attach || attach.expiresAt < Date.now() || attach.mainWebSocket.readyState !== WebSocket.OPEN) { + if (!attach || attach.mainWebSocket.readyState !== WebSocket.OPEN) { if (attach) this.deleteProcessAttachToken(token, attach); return Response.json({ error: 'Invalid or expired process attachment token' }, { status: 401 }); } if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 }); - this.deleteProcessAttachToken(token, attach); + // Keep the token valid after attach so the client can auto-reconnect the process monitor. + // It is deleted in cleanup() when the main SSH session closes. const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; @@ -316,12 +317,15 @@ export class SSHSessionDO implements DurableObject { } private registerProcessAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void { - const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS; + // The process-monitor token intentionally lives for the lifetime of the main SSH + // session (it is removed in cleanup() when that session closes). This lets the client + // re-attach the process monitor after a transient drop instead of being locked out by a + // one-time token. The SSH session setup still gates attach on attach.session being ready. const attach: AuxiliaryAttachToken = { mainWebSocket, attachUrl, - expiresAt, - timeout: setTimeout(() => this.deleteProcessAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS), + expiresAt: Number.MAX_SAFE_INTEGER, + timeout: 0 as unknown as ReturnType, }; this.processAttachTokens.set(token, attach); this.processTokenByMainWebSocket.set(mainWebSocket, token);