feat: 增强进程监控的自动重连功能,支持在意外断开后自动尝试重连

This commit is contained in:
cmliu committed 2026-07-30 17:27:22 +08:00
1 parent b8ec55590c
commit 01ebf2f942
3 files changed
+65 -6

No files matched your search

+1
View File
@@ -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<string, string> {
+55 -1
View File
@@ -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<HTMLProgressElement, number>();
// 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<HTMLButtonElement>('[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;
}
+9 -5
View File
@@ -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<typeof setTimeout>,
};
this.processAttachTokens.set(token, attach);
this.processTokenByMainWebSocket.set(mainWebSocket, token);