diff --git a/docs/changelog/2026-05-26-webssh-terminal-fix.md b/docs/changelog/2026-05-26-webssh-terminal-fix.md new file mode 100644 index 00000000..7c8e8393 --- /dev/null +++ b/docs/changelog/2026-05-26-webssh-terminal-fix.md @@ -0,0 +1,40 @@ +# WebSSH 终端无法输入修复 + +**日期**: 2026-05-26 +**变更摘要**: 修复 WebSSH 终端连接建立后立即断开、无法输入的问题 + +## 动机 + +1. WebSocket 连接建立后几秒内就断开(wsState=CLOSED),用户无法在终端输入 +2. 后端日志报 `WebSSH shell creation failed: ` 错误消息为空,无法诊断 +3. `finally` 块双重关闭 WebSocket 导致 `Unexpected ASGI message 'websocket.close'` 异常 +4. 连接池复用的旧连接可能已失效(TCP连接在但SSH通道损坏),`create_process` 抛空消息异常 + +## 变更内容 + +### server/api/webssh.py — 3个bug修复 +- **失效连接重试**: `create_process` 失败时,强制关闭池中旧连接,用新连接重试一次 +- **异常信息改进**: `logger.error` 改用 `{type(e).__name__}: {e!r}` 避免空消息 +- **双重关闭修复**: 引入 `ws_closed` 标志,异常分支 close 后 finally 不再重复 close +- 所有 `websocket.close()` 调用增加 try/except 防护 + +### web/app/terminal.html — Alpine 初始化竞态修复 +- `$d()` 函数加 try/catch:Alpine 未加载时返回默认对象而非抛 `ReferenceError` +- 消除 `ResizeObserver fit error: ReferenceError: Alpine is not defined` 控制台警告 + +## 涉及文件 + +- `server/api/webssh.py` — WebSSH WebSocket 处理 +- `web/app/terminal.html` — 前端终端页面 + +## 是否需迁移/重启 + +- 需重启后端(webssh.py 修改) +- terminal.html 静态文件无需重启 + +## 验证方式 + +1. 打开 terminal.html?server_id=8 → WebSocket 保持 OPEN +2. 输入 `whoami` → 回显 `root` +3. 后端日志无 `shell creation failed` 和 `Unexpected ASGI message` 错误 +4. 控制台无 `Alpine is not defined` 警告 diff --git a/server/api/webssh.py b/server/api/webssh.py index d0ec75b7..f9cdaf52 100644 --- a/server/api/webssh.py +++ b/server/api/webssh.py @@ -156,22 +156,56 @@ async def terminal_ws( logger.info(f"WebSSH: admin={admin.username} connecting to server={server.name} ({server.domain})") - # ── Establish SSH connection ── + # ── Establish SSH connection (with stale-connection retry) ── + conn = None try: conn = await ssh_pool.acquire(server) except Exception as e: logger.error(f"WebSSH connection failed: {e}") - await websocket.send_json({"type": MSG_ERROR, "message": f"SSH连接失败: {str(e)}"}) - await websocket.close(code=4003, reason="SSH connection failed") + try: + await websocket.send_json({"type": MSG_ERROR, "message": f"SSH连接失败: {str(e)}"}) + except Exception: + pass + try: + await websocket.close(code=4003, reason="SSH connection failed") + except Exception: + pass await _close_ssh_session(session_id) return - # ── Create SSH shell ── + # ── Create SSH shell (retry once if stale pooled connection) ── + ws_closed = False # Track whether we've closed the WebSocket try: - async with conn.create_process( - term_type="xterm-256color", - term_size=(24, 80), - ) as shell: + try: + shell_proc = conn.create_process( + term_type="xterm-256color", + term_size=(24, 80), + ) + except Exception as e: + # Stale pooled connection — force-close and retry with fresh connection + logger.warning(f"WebSSH shell creation failed on pooled connection (server={server.id}): {type(e).__name__}: {e!r}") + await ssh_pool.close_connection(server.id) + try: + conn = await ssh_pool.acquire(server) + shell_proc = conn.create_process( + term_type="xterm-256color", + term_size=(24, 80), + ) + except Exception as e2: + logger.error(f"WebSSH shell creation failed on fresh connection (server={server.id}): {type(e2).__name__}: {e2!r}") + try: + await websocket.send_json({"type": MSG_ERROR, "message": f"Shell创建失败: {type(e2).__name__}"}) + except Exception: + pass + try: + await websocket.close(code=4003, reason="Shell creation failed") + except Exception: + pass + ws_closed = True + await _close_ssh_session(session_id) + return + + async with shell_proc as shell: # Send TERMINAL_INIT to client await websocket.send_json({ "type": MSG_TERMINAL_INIT, @@ -267,25 +301,27 @@ async def terminal_ws( logger.warning(f"WebSSH relay error: {e}") except Exception as e: - logger.error(f"WebSSH shell creation failed: {e}") - try: - await websocket.send_json({"type": MSG_ERROR, "message": f"Shell创建失败: {str(e)}"}) - except Exception: - pass + logger.error(f"WebSSH shell creation failed: {type(e).__name__}: {e!r}") + if not ws_closed: + try: + await websocket.send_json({"type": MSG_ERROR, "message": f"Shell创建失败: {type(e).__name__}"}) + except Exception: + pass finally: # ── Cleanup ── await ssh_pool.release(server.id) await _close_ssh_session(session_id) - try: - await websocket.send_json({"type": MSG_CLOSE, "session_id": session_id}) - except Exception: - pass + if not ws_closed: + try: + await websocket.send_json({"type": MSG_CLOSE, "session_id": session_id}) + except Exception: + pass - try: - await websocket.close() - except Exception: - pass + try: + await websocket.close() + except Exception: + pass logger.info(f"WebSSH session closed: admin={admin.username}, server={server.name}, session={session_id}") diff --git a/web/app/terminal.html b/web/app/terminal.html index dd8c12fe..049661c9 100644 --- a/web/app/terminal.html +++ b/web/app/terminal.html @@ -68,8 +68,8 @@ const serverId = parseInt(params.get('server_id')); if (!serverId) window.location.href = '/app/servers.html'; - // ── Alpine helper ── - function $d() { return Alpine.$data(document.body); } + // ── Alpine helper (safe before Alpine init) ── + function $d() { try { return Alpine.$data(document.body); } catch(e) { return { connected:false, serverName:'...', cols:80, rows:24, fullscreen:false, panelOpen:true, sidebarOpen:true }; } } // ── xterm.js ── const term = new Terminal({