From 2535a04d2baa6adeac6f707a3132aacb3d716903 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 6 Nov 2026 20:02:52 +0800 Subject: [PATCH] [fix] recover initial MCP signaling connections and preserve failure reasons --- apps/cli/docs/automation-protocol.md | 2 ++ apps/mcp/README.md | 2 ++ apps/mcp/src/common.ts | 2 ++ apps/mcp/src/main.ts | 3 +- apps/mcp/src/sessions.ts | 31 +++++++++++++++---- apps/mcp/src/terminal.ts | 3 +- apps/mcp/ui/viewer.ts | 23 +++++++++++++- deps/submodules/minirtc | 2 +- .../controller/src/controller_diagnostics.cpp | 15 +++++++-- libs/controller/src/controller_session.cpp | 17 +++++++--- 10 files changed, 83 insertions(+), 17 deletions(-) diff --git a/apps/cli/docs/automation-protocol.md b/apps/cli/docs/automation-protocol.md index 0aa5eed8..60bde6d7 100644 --- a/apps/cli/docs/automation-protocol.md +++ b/apps/cli/docs/automation-protocol.md @@ -42,6 +42,8 @@ The output queue is bounded to 64 messages and 8 MiB; a blocked output pipe term `connect` returns immediately after initiating transport. Poll `status` until `state == "ready"`; this requires a frame from the current display generation. Status includes `transport_state`, `geometry_epoch`, `frame_age_ms`, `source_size`, `control_allowed`, `human_paused`, host platform, displays and transport diagnostics. +During initial `registering`, `SignalReconnecting` keeps the existing signaling peer and its backoff worker alive within the original 30-second connection deadline. Expiry in that phase reports `signaling_timeout`; expiry after submitting a device join reports `connection_timeout`. Certificate validation and login rejection terminate the attempt immediately. A signaling interruption after join submission also terminates the attempt. Explicit cancellation closes the peer and its retry worker. The MCP adapter forwards recognized status errors as an optional `error.reason` alongside its public error category. + ## Live video `doctor --json` advertises `automation_video_transport: "loopback-websocket/1"`, `automation_video_protocols: ["h264-annexb/1", "nv12/1"]`, and `automation_video_decoders: ["auto", "software"]`. `hello` repeats these without the `automation_` prefix. The MCP adapter requires the transport capability, so an old pipe-only CLI is rejected. Node spawns only stdin, stdout, stderr, and the credential pipe; live video never enters Node. diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 48cf9059..3a671a3e 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -63,6 +63,8 @@ Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指 面板发起的连接失败后,按 `view_id` 等待也会返回 `failed` 和错误码,避免 Agent 一直停留在等待用户状态。断开正在建立的连接会取消整个连接尝试。多个面板和 Agent 同时查询状态时共用一次原生查询,单次查询取消或暂时繁忙不会断开会话。`crossdesk_connect` 在连接成功但面板初始化失败时仍返回有效的 `session_id`,并附加 `panel_error`。 +初次信令注册使用原生连接的整体 30 秒期限,期间允许 MiniRTC 按退避策略恢复临时传输异常。期限覆盖所有初始重试。证书校验失败、登录被拒绝,以及设备连接请求发出后的信令中断会结束当前尝试。连接失败结果使用 `error.code` 表示错误类别,并以 `error.reason` 提供已识别的原生原因,例如 `signaling_timeout`、`signaling_disconnected` 或 `device_offline_or_unavailable`。面板显示对应说明,终端错误包含原因标识。`logs/transport.log` 记录连接阶段、受控的传输错误分类与数字码、连接关闭码和重试退避信息。 + 自动继续依赖宿主保持当前 Agent 任务及工具调用流程。它不通过网页伪造用户消息,也不承诺从已经结束的对话中主动唤醒 Agent;此类唤醒由宿主负责。 人工暂停由原生层锁定,模型不能通过 `set_control(true)` 恢复;只有当前授权面板的恢复按钮可以恢复。仅查看连接不能升级为控制连接,需重新连接并授权。关闭/隐藏面板仅停止预览,远程会话保留;断开按钮或 `crossdesk_disconnect` 才结束会话。 diff --git a/apps/mcp/src/common.ts b/apps/mcp/src/common.ts index b56e6f0d..f1a09096 100644 --- a/apps/mcp/src/common.ts +++ b/apps/mcp/src/common.ts @@ -13,6 +13,7 @@ export class Failure extends Error { public code: string, public sideEffect = false, public retryable = false, + public reason?: string, ) { super(code); } @@ -23,6 +24,7 @@ export function failure(error: unknown): Data { code: e.code, retryable: e.retryable, side_effect_possible: e.sideEffect, + ...(e.reason ? { reason: e.reason } : {}), }; } export function parse( diff --git a/apps/mcp/src/main.ts b/apps/mcp/src/main.ts index 2b2d5ebb..ed5779bf 100644 --- a/apps/mcp/src/main.ts +++ b/apps/mcp/src/main.ts @@ -201,6 +201,7 @@ async function main() { }); } main().catch((error) => { - process.stderr.write(`crossdesk-mcp: ${failure(error).code}\n`); + const result = failure(error); + process.stderr.write(`crossdesk-mcp: ${result.code}${result.reason ? ` (${result.reason})` : ""}\n`); process.exitCode = 1; }); diff --git a/apps/mcp/src/sessions.ts b/apps/mcp/src/sessions.ts index 9eb46dff..bf243880 100644 --- a/apps/mcp/src/sessions.ts +++ b/apps/mcp/src/sessions.ts @@ -15,6 +15,26 @@ import { DeviceStore, type Device } from "./store.js"; import { nativeBinary, NativeClient } from "./native.js"; import { displayEnvironment } from "./environment.js"; +// Keep the public error categories while exposing only known native reasons. +const connectionErrors = new Map([ + ["incorrect_password", "AUTH_FAILED"], + ["signaling_timeout", "CONNECTION_TIMEOUT"], + ["connection_timeout", "CONNECTION_TIMEOUT"], + ["signaling_login_failed", "CONNECTION_FAILED"], + ["signaling_disconnected", "CONNECTION_FAILED"], + ["tls_certificate_error", "CONNECTION_FAILED"], + ["cli_identity_not_supported", "CONNECTION_FAILED"], + ["join_request_failed", "CONNECTION_FAILED"], + ["device_not_found", "CONNECTION_FAILED"], + ["device_offline_or_unavailable", "CONNECTION_FAILED"], + ["connection_lost", "CONNECTION_FAILED"], + ["connection_failed", "CONNECTION_FAILED"], + ["remote_disconnected", "CONNECTION_FAILED"], + ["display_not_available", "CONNECTION_FAILED"], + ["media_initialization_failed", "CONNECTION_FAILED"], + ["control_event_overflow", "CONNECTION_FAILED"], +]); + export interface View { id: string; generation: number; @@ -346,12 +366,11 @@ export class Sessions { s.connecting = false; return { ...s.status, session_id: s.id, device_alias: s.device.alias }; } - if (["failed", "closed", "disconnected"].includes(s.status.state)) - throw new Failure( - s.status.error === "incorrect_password" - ? "AUTH_FAILED" - : "CONNECTION_FAILED", - ); + if (["failed", "closed", "disconnected"].includes(s.status.state)) { + const code = connectionErrors.get(s.status.error); + throw new Failure(code ?? "CONNECTION_FAILED", false, false, + code ? s.status.error : undefined); + } await delay(100, undefined, { signal }); } throw new Failure("CONNECTION_TIMEOUT"); diff --git a/apps/mcp/src/terminal.ts b/apps/mcp/src/terminal.ts index ea9289fe..48f03d18 100644 --- a/apps/mcp/src/terminal.ts +++ b/apps/mcp/src/terminal.ts @@ -161,7 +161,8 @@ export class TerminalClient { if (!buffer.includes(10)) return; try { const reply = JSON.parse(buffer.toString("utf8")); - if (reply.error) finish(new Failure(reply.error.code)); + if (reply.error) finish(new Failure(reply.error.code, + !!reply.error.side_effect_possible, !!reply.error.retryable, reply.error.reason)); else if (reply.result && typeof reply.result === "object") finish(undefined, reply.result); else finish(new Failure("INVALID_RESPONSE")); } catch { finish(new Failure("INVALID_RESPONSE")); } diff --git a/apps/mcp/ui/viewer.ts b/apps/mcp/ui/viewer.ts index acdeb25d..a74bd130 100644 --- a/apps/mcp/ui/viewer.ts +++ b/apps/mcp/ui/viewer.ts @@ -96,6 +96,22 @@ const messages: Record = { CONNECTION_FAILED: "连接失败,请检查设备是否在线及服务器设置。", CONNECTION_TIMEOUT: "等待远端画面超时,请检查远端状态后重试。", CONNECTION_LOST: "远程连接已中断。请检查远端状态并重新连接。", + signaling_timeout: "连接信令服务器超时,请检查本机网络、代理和服务器状态。", + signaling_disconnected: "信令连接已中断,请检查本机网络、代理和服务器状态。", + signaling_login_failed: "信令服务器登录失败,请检查服务器配置。", + tls_certificate_error: "信令服务器证书验证失败,请检查证书和本机时间。", + cli_identity_not_supported: "信令服务器需要支持 CLI 身份注册,请更新服务器。", + join_request_failed: "发送设备连接请求失败,请检查信令连接。", + incorrect_password: "访问密码错误,请重新输入。", + device_not_found: "设备 ID 无效或已过期,请确认设备 ID。", + device_offline_or_unavailable: "远端设备暂不可连接,请检查被控端的在线状态。", + connection_timeout: "等待远端连接超时,请检查被控端和网络状态。", + connection_failed: "远端连接建立失败,请检查双方网络和中继服务器状态。", + connection_lost: "远端连接已中断,请检查双方网络状态。", + remote_disconnected: "远端已断开连接。", + display_not_available: "所选显示器当前不可用,请检查显示器索引。", + media_initialization_failed: "本机媒体组件初始化失败,请检查原生 CLI 运行环境。", + control_event_overflow: "连接事件队列已满,请稍后重新连接。", SESSION_LIMIT: "已有活动连接,请先断开当前电脑。", DEVICE_BUSY: "另一实例正在使用此设备。", CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。", @@ -168,7 +184,12 @@ function showError(error: unknown) { element("error").hidden = false; } function check(result: Data): Data { - if (result.error) throw new Error(result.error.code ?? "INTERNAL_ERROR"); + if (result.error) { + const error = typeof result.error === "string" + ? { code: "CONNECTION_FAILED", reason: result.error } : result.error; + throw new Error(Object.hasOwn(messages, error.reason) + ? error.reason : error.code ?? "INTERNAL_ERROR"); + } return result; } async function devices() { diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index 4efc904b..f67e251a 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit 4efc904bdf2106553d34030450d5cc89c937dc52 +Subproject commit f67e251af32fe74029f249ef393336db2f1a8ff2 diff --git a/libs/controller/src/controller_diagnostics.cpp b/libs/controller/src/controller_diagnostics.cpp index e37f69c0..fecb072c 100644 --- a/libs/controller/src/controller_diagnostics.cpp +++ b/libs/controller/src/controller_diagnostics.cpp @@ -14,6 +14,17 @@ std::string DiagnosticEvent(const spdlog::details::log_msg &message) { const std::string_view text(message.payload.data(), message.payload.size()); if (source == "controller_session.cpp") return std::string(text); // Local states/counters only at these call sites. + if (source == "ws_client.cpp") { + if (text == "WebSocket connection opened") return std::string(text); + // These call sites contain library category names, numeric codes and retry + // counters. Keep free-form errors, server close reasons and URLs filtered. + for (const std::string_view prefix : { + "Signaling transport failure: category=", + "Signaling transport closed: code=", + "Scheduling signaling reconnect: attempt=", + "Will retry connection after "}) + if (text.substr(0, prefix.size()) == prefix) return std::string(text); + } if (source == "ice_transport.cpp") { const auto marker = text.rfind("state_change: "); if (marker != std::string_view::npos) { @@ -70,10 +81,8 @@ private: void InitializeControllerDiagnostics(const std::string &directory) { auto sink = std::make_shared(directory); - minirtc::InitLogger(directory); + minirtc::InitLogger(directory, std::move(sink)); auto logger = minirtc::get_logger(); - // Connect calls this after the previous peer and its callbacks stop. - logger->sinks() = {std::move(sink)}; logger->set_level(spdlog::level::info); } } // namespace crossdesk diff --git a/libs/controller/src/controller_session.cpp b/libs/controller/src/controller_session.cpp index cf97ff41..4dcc6600 100644 --- a/libs/controller/src/controller_session.cpp +++ b/libs/controller/src/controller_session.cpp @@ -168,6 +168,10 @@ void ControllerSession::Tick() { Fail("control_event_overflow"); return; } + if (!Connected() && std::chrono::steady_clock::now() >= deadline_) { + Fail(state_ == "registering" ? "signaling_timeout" : "connection_timeout"); + return; + } for (auto &e : pending) { if (Finished()) break; @@ -181,6 +185,13 @@ void ControllerSession::Tick() { Fail("signaling_login_failed"); break; } + // The initial signaling transport can recover before a remote join is + // sent. Keep its backoff worker alive within the original deadline. + if (e.code == SignalReconnecting && state_ == "registering") { + SPDLOG_LOGGER_INFO(minirtc::get_logger(), + "Initial signaling retry within connection deadline"); + continue; + } if (e.code == SignalClosed || e.code == SignalServerClosed || e.code == SignalReconnecting) { Fail("signaling_disconnected"); @@ -258,9 +269,6 @@ void ControllerSession::Tick() { FreeRemoteAction(a); } } - if (!Connected() && !Finished() && - std::chrono::steady_clock::now() > deadline_) - Fail("connection_timeout"); } bool ControllerSession::Send(const char *stream, const std::string &payload) { return peer_ && Connected() && !payload.empty() && @@ -282,7 +290,8 @@ void ControllerSession::ClosePeers() { password_.clear(); } void ControllerSession::Fail(const char *reason) { - SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Session failed: {}", reason); + SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Session failed: {} (stage {})", + reason, state_); ReleaseInput(); error_ = reason; state_ = "failed";