fix(rdp): guacd 后台读取队列 + 隧道超时与尺寸

独立 guacd reader 避免 WS 竞争丢数据;ping 回显与 sync 已生产验证;前端 90s 超时与 OPEN 后发尺寸。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-10 21:54:50 +08:00
parent af47b24450
commit 341b130d9b
4 changed files with 126 additions and 41 deletions
@@ -0,0 +1,9 @@
# 2026-06-10 — RDP guacd 后台读取队列
## 摘要
WebSocket 中继改为独立 guacd 读取任务 + 队列,避免 cancel 读任务丢 TCP 数据;前端隧道 OPEN 后发送尺寸。
## 验证
生产容器内 ping 回显 + 20s 内收到 sync。
@@ -0,0 +1,20 @@
# 2026-06-10 — RDP WebSocket 单循环中继(修复 Server timeout
## 摘要
修复 Guacamole 客户端报 `Server timeout.`:双协程并发 `send/receive` 违反 Starlette WebSocket 约束,隧道 ping 无法回显;改为单循环中继,并按指令拆分转发。
## 动机
客户端 `receiveTimeout` 默认 15s,期间必须收到服务端数据(含 ping 回显)。并发 `ws_to_guacd` / `guacd_to_ws` 会导致 ping 丢失。
## 涉及文件
- `server/infrastructure/guacamole/ws_tunnel.py`
- `server/infrastructure/guacamole/protocol.py``iter_raw_instructions`
- `frontend/src/composables/rdp/useRdpSession.ts``receiveTimeout=90s`
- `tests/test_guacamole_protocol.py`
## 验证
生产:3389 连接不再 15s 超时;guacd 日志有 RDPDR 连接。
+18 -2
View File
@@ -31,7 +31,11 @@ function buildConnectString(info: RdpConnectInfo): string {
}
function tunnelErrorMessage(st: { code?: number; message?: string }): string {
return st?.message || `RDP 隧道错误 (${st?.code ?? 'unknown'})`
const msg = (st?.message || '').trim()
if (/server timeout/i.test(msg)) {
return '连接超时:服务端未收到远程桌面数据,请稍后重试'
}
return msg || `RDP 隧道错误 (${st?.code ?? 'unknown'})`
}
export function useRdpSession() {
@@ -116,6 +120,9 @@ export function useRdpSession() {
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
)
const rdpTunnel = new Guacamole.WebSocketTunnel(wsUrl)
// RDP handshake to Azure can exceed the default 15s; pings need timely echo from server.
rdpTunnel.receiveTimeout = 90_000
rdpTunnel.unstableThreshold = 15_000
tunnel = rdpTunnel
const rdpClient = new Guacamole.Client(rdpTunnel)
client = rdpClient
@@ -130,7 +137,9 @@ export function useRdpSession() {
rdpClient.onerror = (st) => fail(tunnelErrorMessage(st))
rdpClient.onstatechange = (state: number) => {
if (state === Guacamole.Client.STATE_CONNECTED) {
if (state === Guacamole.Client.STATE_WAITING) {
status.value = 'connecting'
} else if (state === Guacamole.Client.STATE_CONNECTED) {
status.value = 'connected'
sendDisplaySize()
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
@@ -138,6 +147,13 @@ export function useRdpSession() {
}
}
rdpTunnel.onstatechange = (state: number) => {
// Guacamole.Tunnel.State.OPEN === 1
if (state === 1 && status.value === 'connecting') {
sendDisplaySize()
}
}
const display = rdpClient.getDisplay()
display.onresize = () => sendDisplaySize()
+76 -36
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
from fastapi import WebSocket, WebSocketDisconnect
@@ -11,12 +12,83 @@ from server.infrastructure.guacamole.protocol import (
GuacdStream,
guacd_rdp_handshake,
is_internal_instruction,
iter_raw_instructions,
tunnel_open_instruction,
)
logger = logging.getLogger("nexus.guacamole.ws_tunnel")
async def _guacd_reader_loop(stream: GuacdStream, out: asyncio.Queue[bytes | None]) -> None:
"""Continuously read complete guacd instructions into a queue (never cancel mid-read)."""
try:
while True:
batch = await stream.read_raw_batch()
if not batch:
break
await out.put(batch)
except ConnectionError:
pass
except Exception as exc:
logger.warning("guacd reader ended: %s", exc)
finally:
await out.put(None)
async def _relay_ws_guacd(
websocket: WebSocket,
stream: GuacdStream,
writer: asyncio.StreamWriter,
) -> None:
"""Single WS consumer; guacd reads run in a dedicated task (Starlette-safe)."""
guacd_queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=512)
reader_task = asyncio.create_task(_guacd_reader_loop(stream, guacd_queue))
ws_lock = asyncio.Lock()
async def ws_send(text: str) -> None:
async with ws_lock:
await websocket.send_text(text)
async def forward_client_message(data: str) -> None:
for inst in iter_raw_instructions(data):
if is_internal_instruction(inst):
await ws_send(inst)
else:
writer.write(inst.encode("utf-8"))
await writer.drain()
try:
while True:
ws_recv_task = asyncio.create_task(websocket.receive_text())
guacd_recv_task = asyncio.create_task(guacd_queue.get())
done, pending = await asyncio.wait(
{ws_recv_task, guacd_recv_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if ws_recv_task in done:
exc = ws_recv_task.exception()
if exc is not None:
if isinstance(exc, WebSocketDisconnect):
return
raise exc
await forward_client_message(ws_recv_task.result())
if guacd_recv_task in done:
batch = guacd_recv_task.result()
if batch is None:
return
await ws_send(batch.decode("utf-8", errors="replace"))
finally:
reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reader_task
async def run_guacamole_rdp_tunnel(
websocket: WebSocket,
guacd_host: str,
@@ -38,50 +110,18 @@ async def run_guacamole_rdp_tunnel(
except Exception as exc:
logger.warning("guacd handshake failed: %s", exc)
writer.close()
try:
with contextlib.suppress(Exception):
await writer.wait_closed()
except Exception:
pass
await websocket.close(code=1011, reason=str(exc)[:120])
return
async def ws_to_guacd() -> None:
try:
while True:
data = await websocket.receive_text()
if is_internal_instruction(data):
# Tunnel stability pings use empty opcode — echo, do not forward to guacd.
await websocket.send_text(data)
continue
writer.write(data.encode("utf-8"))
await writer.drain()
await _relay_ws_guacd(websocket, stream, writer)
except WebSocketDisconnect:
pass
except Exception as exc:
logger.debug("RDP WS→guacd ended: %s", exc)
async def guacd_to_ws() -> None:
try:
while True:
# Must not split mid-instruction — guacamole-common-js parses per WS message.
batch = await stream.read_raw_batch()
if not batch:
break
await websocket.send_text(batch.decode("utf-8", errors="replace"))
except ConnectionError:
pass
except Exception as exc:
logger.debug("RDP guacd→WS ended: %s", exc)
ws_task = asyncio.create_task(ws_to_guacd())
guacd_task = asyncio.create_task(guacd_to_ws())
try:
await asyncio.wait([ws_task, guacd_task], return_when=asyncio.FIRST_COMPLETED)
logger.warning("RDP relay ended: %s", exc)
finally:
for task in (ws_task, guacd_task):
task.cancel()
writer.close()
try:
with contextlib.suppress(Exception):
await writer.wait_closed()
except Exception:
pass