[fix] recover initial MCP signaling connections and preserve failure reasons

This commit is contained in:
dijunkun
2026-11-06 20:02:52 +08:00
parent 09ef60dc6f
commit 2535a04d2b
10 changed files with 83 additions and 17 deletions
+2
View File
@@ -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.
+2
View File
@@ -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` 才结束会话。
+2
View File
@@ -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<T extends z.ZodType>(
+2 -1
View File
@@ -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;
});
+25 -6
View File
@@ -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<string, string>([
["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");
+2 -1
View File
@@ -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")); }
+22 -1
View File
@@ -96,6 +96,22 @@ const messages: Record<string, string> = {
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() {
+12 -3
View File
@@ -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<DiagnosticSink>(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
+13 -4
View File
@@ -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";