优化: 跨端链路协议对齐

- miniapp 配置域旧端点强制重置
- df-tunnel 隧道协议与 relay 现网对齐
- relay e2e 例程入 examples
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent 7aa0da32f2
commit d9e0ce73d1
9 changed files with 248 additions and 126 deletions
+68
View File
@@ -0,0 +1,68 @@
//! df-tunnel ↔ u-relay 端到端联调(Agent 侧握手 + 存活 + 断开)
//!
//! 起 WsTunnelClient 连本机 u-relay :19180 /ws/agent:
//! 1. Hello{kind:"agent", channel_id, token} → u-relay 立即回 hello_ack(3s PROBE 内收到)
//! 2. send_raw_event 发一条事件(relay 无 commander 在线 → 静默丢弃,不报错)
//! 3. 存活 2s(收发循环稳定,无 panic)
//! 4. 优雅 disconnect
//!
//! 运行:先起 u-relay(U_RELAY_TOKEN=devflow-relay-default-token U_RELAY_ADDR=127.0.0.1:19180),
//! 再 `cargo run -p df-tunnel --example e2e_u_relay`。
use std::sync::Arc;
use std::time::Duration;
use df_tunnel::tunnel::CommandHandler;
use df_tunnel::{TunnelClient, WsTunnelClient};
use tokio::sync::Mutex;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = "ws://127.0.0.1:19180/ws/agent";
let channel_id = "test-channel-e2e-01";
let token = "devflow-relay-default-token";
let client = Arc::new(WsTunnelClient::new());
// 注册 command 回调(收 commander 指令;本测试预计不触发)
let got_cmd = Arc::new(Mutex::new(0usize));
let counter = got_cmd.clone();
let on_command: CommandHandler = Arc::new(move |_payload| {
let counter = counter.clone();
Box::pin(async move {
*counter.lock().await += 1;
})
});
println!("[1] connect {url} channel={channel_id}");
client
.connect(url, channel_id, token, on_command)
.await
.map_err(|e| anyhow::anyhow!("connect 失败: {e}"))?;
println!(" connect Ok");
assert!(client.is_connected(), "is_connected 应为 true");
println!("[2] is_connected == true ✓(u-relay 已回 hello_ack,握手成功)");
// 发一条原始事件(relay 无 commander 在线 → 静默丢弃,不应报错)
client
.send_raw_event(serde_json::json!({"type": "e2e_probe", "note": "df-tunnel e2e"}))
.await
.map_err(|e| anyhow::anyhow!("send_raw_event 失败: {e}"))?;
println!("[3] send_raw_event Ok(事件出站;relay 无对端静默丢弃符合预期)");
// 保持 2s 让心跳/收发循环稳定跑(无 panic 即可)
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(client.is_connected(), "2s 后仍应 connected(心跳保活)");
println!("[4] 2s 存活 ✓");
// 优雅断开
client.disconnect().await?;
assert!(!client.is_connected(), "disconnect 后 is_connected 应为 false");
println!("[5] disconnect Ok,is_connected == false ✓");
let got = *got_cmd.lock().await;
println!("───");
println!("df-tunnel ↔ u-relay E2E: 5/5 通过(command 收到 {got} 条,预期 0)");
Ok(())
}
+27 -16
View File
@@ -11,41 +11,51 @@
use serde::{Deserialize, Serialize};
/// 握手身份宣告帧(对齐 df-relay::relay::Hello)
/// 握手身份宣告帧(对齐 u-relay::relay::Hello)
///
/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。
///
/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
///
/// 历史:df-relay 一代协议用 device_id + kind:"device",已在 u-relay 现行版全部下线
/// (端点 /ws/agent,kind:"agent",配对键 channel_id,旧名握手期直接拒绝)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// 客户端类型("device" / "miniapp"),桌面端恒为 Device
/// 客户端类型("agent" / "commander"),桌面端恒为 Agent
pub kind: HelloKind,
/// 配对绑定的设备 ID
pub device_id: String,
/// 配对 token(env DF_RELAY_TOKEN 同源)
/// 配对绑定的通道 ID(非空,≤128B,无控制字符)
pub channel_id: String,
/// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String,
}
/// Hello.kind 传输表示(serde 字符串,与 df-relay::ClientKindWire 一致)
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
///
/// 现行仅两值:Agent(执行端,如 df-tunnel)/ Commander(控制端,如 df-miniapp)。
/// 历史名 Device/Miniapp/Runner/Console/Executor/Controller 已全部下线。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HelloKind {
Device,
Miniapp,
Agent,
Commander,
}
/// relay 控制面错误帧(握手失败时 relay 发送)
///
/// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。
/// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。
///
/// 现行 u-relay 错误码:handshake_failed / kind_mismatch / invalid_channel_id /
/// server_misconfigured / auth_failed。历史 `invalid_device_id` 已下线。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayControlError {
/// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段)
pub kind: String,
/// 错误原因(handshake_failed / kind_mismatch / auth_failed)
/// 错误原因(handshake_failed / kind_mismatch / invalid_channel_id /
/// server_misconfigured / auth_failed)
pub error: String,
}
@@ -112,10 +122,11 @@ impl TunnelEvent {
}
}
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
/// 云后端 → 桌面端的操作指令(控制端远程操作映射)
///
/// 对齐设计文档「Layer1 指令路由」:
/// send/stop/approve/regenerate/switch 五类操作。
/// 由控制端(df-miniapp 等 Commander)发出,经 u-relay 路由到本桌面端 Agent。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelCommand {
@@ -133,8 +144,8 @@ pub enum TunnelCommand {
/// 隧道传输的消息包装(双向)
///
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
/// 区分「桌面端 → u-relay → 控制端」的事件流(TunnelEvent)
/// 与「控制端 → u-relay → 桌面端」的指令流(TunnelCommand)。
/// 同一 WS 连接复用,靠 kind 标签区分。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "dir", rename_all = "snake_case")]
+3 -3
View File
@@ -3,9 +3,9 @@
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
//!
//! ## 职责
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
//! - 主动连接云后端 u-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 控制端(df-miniapp 等)
//! - 指令路由:接收控制端远程操作 → 调对应 Tauri command
//!
//! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
+35 -31
View File
@@ -1,18 +1,20 @@
//! df-tunnel 隧道客户端(Phase2 完整实现)
//! df-tunnel 隧道客户端(Phase2 完整实现,2026-10-04 切换 u-relay 协议)
//!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/agent`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
//!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
//! - relay 双因子校验(kind == expected_device + token 匹配),失败发
//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack)
//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧)
//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的
//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析)
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage)
//! ## 协议对齐(u-relay 现行,2026-10-04 起)
//! - 桌面端连 `/ws/agent`,首帧发 `Hello { kind:"agent", channel_id, token }`
//! - relay 双因子校验(kind == agent + token 匹配 + channel_id 格式校验),
//! 失败发 `{"kind":"control","error":"..."}` + Close,
//! 成功立即回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` 显式 ack
//! - 握手成功信号:**收到 hello_ack 控制帧 或 收到任何非 error 首帧**(u-relay 有显式 ack)
//! - 入站消息:relay 把对端 Commander 的文本帧包成 BroadcastMessage 转发,桌面端收到的
//! 是完整 BroadcastMessage JSON,payload 是 commander 指令原样 JSON(纯透传,不解析)
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 Event 广播)
//! - 心跳:relay 本地回 pong(`{"control_kind":"ping"}` → pong 不经对端),路径短延迟低
//!
//! ## AiChatEvent 透传原则
//! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。
@@ -21,7 +23,7 @@
//! ## 并发模型
//! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。
//! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。
//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 device 端桥接层(src-tauri)。
//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 agent 端桥接层(src-tauri)。
//! `&self` + `Arc<Mutex<...>>` 持连接状态,trait 方法全部 async + 不持 stream 借用。
use std::sync::atomic::{AtomicBool, Ordering};
@@ -38,7 +40,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
/// 收到 miniapp→device 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
///
/// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型,
/// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。
@@ -61,14 +63,14 @@ pub type CommandHandler = Arc<dyn Fn(serde_json::Value) -> futures_util::future:
pub trait TunnelClient: Send + Sync {
/// 主动连接云后端 + 注册指令回调
///
/// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。
/// `url` 形如 `wss://host/ws/agent`(query 不带 token,token 走 Hello 帧)。
/// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。
/// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。
/// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 device 端桥接层 match cmd 路由。
/// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 agent 端桥接层 match cmd 路由。
async fn connect(
&self,
url: &str,
device_id: &str,
channel_id: &str,
token: &str,
on_command: CommandHandler,
) -> Result<()>;
@@ -79,12 +81,12 @@ pub trait TunnelClient: Send + Sync {
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
fn is_connected(&self) -> bool;
/// 发送事件(桌面端 → 云后端 → 小程序)
/// 发送事件(桌面端 → 云后端 → 控制端)
///
/// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
/// 发送原始事件(桌面端 → 云后端 → 控制端),payload 为 AiChatEvent 序列化 JSON Value
///
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
@@ -92,7 +94,7 @@ pub trait TunnelClient: Send + Sync {
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
}
/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧;
/// 握手探测窗口(u-relay 校验通过会立即回 hello_ack 控制帧;
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
@@ -104,6 +106,7 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
/// 重连初始退避
@@ -136,7 +139,7 @@ pub struct WsTunnelClient {
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。
server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>,
channel_id: Mutex<Option<String>>,
token: Mutex<Option<String>>,
/// 当前活跃连接状态(None = 未连接)
conn: Mutex<Option<ConnState>>,
@@ -150,19 +153,19 @@ impl WsTunnelClient {
pub fn new() -> Self {
Self {
server_url: Mutex::new(None),
device_id: Mutex::new(None),
channel_id: Mutex::new(None),
token: Mutex::new(None),
conn: Mutex::new(None),
connected: Arc::new(AtomicBool::new(false)),
}
}
/// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连复用)
/// 当前已连接时取一份重连参数(URL/channel_id/token,供调用方 supervisor 断线重连复用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?;
let channel_id = self.channel_id.lock().await.clone()?;
let token = self.token.lock().await.clone()?;
Some((url, device_id, token))
Some((url, channel_id, token))
}
}
@@ -177,7 +180,7 @@ impl TunnelClient for WsTunnelClient {
async fn connect(
&self,
url: &str,
device_id: &str,
channel_id: &str,
token: &str,
on_command: CommandHandler,
) -> Result<()> {
@@ -204,8 +207,8 @@ impl TunnelClient for WsTunnelClient {
// 2. 发 Hello 握手帧
let hello = Hello {
kind: HelloKind::Device,
device_id: device_id.to_string(),
kind: HelloKind::Agent,
channel_id: channel_id.to_string(),
token: token.to_string(),
};
let hello_json = serde_json::to_string(&hello)?;
@@ -216,13 +219,14 @@ impl TunnelClient for WsTunnelClient {
// 3. 探测握手结果:HANDSHAKE_PROBE 窗口内
// - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因)
// - 收到 Close / EOF → 连接被关(返回 Connect 错误)
// - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环)
// - 收到 hello_ack / 首条正常消息 / 超时无负帧 → 视为握手成功(u-relay 显式 ack)
// - Ping/Pong 协议帧:忽略继续等
let mut first_frame_pending: Option<WsMessage> = None;
let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await;
match probe {
Err(_) => {
// 超时无负帧:relay 静默通过(常见路径)
// 超时无负帧:hello_ack 错过探测窗(可能性低)或 relay 异常,
// 仍按成功处理(后续帧会进 run_loop 正常分派)
tracing::debug!("握手探测窗口无负帧,视为成功");
}
Ok(None) => {
@@ -253,7 +257,7 @@ impl TunnelClient for WsTunnelClient {
// 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环
*self.server_url.lock().await = Some(url.to_string());
*self.device_id.lock().await = Some(device_id.to_string());
*self.channel_id.lock().await = Some(channel_id.to_string());
*self.token.lock().await = Some(token.to_string());
let (tx, rx) = mpsc::unbounded_channel::<OutFrame>();
@@ -271,7 +275,7 @@ impl TunnelClient for WsTunnelClient {
));
*self.conn.lock().await = Some(ConnState { tx, task });
tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动");
tracing::info!(%url, channel_id, "WsTunnelClient 连接就绪,收发循环已启动");
Ok(())
}
@@ -332,7 +336,7 @@ impl WsTunnelClient {
}
}
*self.server_url.lock().await = None;
*self.device_id.lock().await = None;
*self.channel_id.lock().await = None;
*self.token.lock().await = None;
self.connected.store(false, Ordering::Relaxed);
}