fix: WebSSH terminal disconnect after connect + stale pool retry
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

- Fix shell creation failure on stale pooled SSH connections:
  force-close and retry with fresh connection
- Fix empty error message in logs: use {type(e).__name__}: {e!r}
- Fix double WebSocket.close() causing RuntimeError in finally block:
  track ws_closed flag to avoid closing twice
- Fix Alpine undefined error in terminal.html: $d() now returns
  default object when Alpine hasn't initialized yet
- All websocket.close() calls now wrapped in try/except

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-26 21:33:31 +08:00
parent b1aa235726
commit 8b10f9d64a
3 changed files with 99 additions and 23 deletions
@@ -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` 警告
+57 -21
View File
@@ -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}")
+2 -2
View File
@@ -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({