sfo-cmd-server 0.3.2

command server implement
Documentation
# Client / Node / Server 设计说明

## 1. 范围

本文基于当前仓库实现,梳理 `sfo-cmd-server` 的 client/node/server 设计,覆盖:

- 协议帧与 body 生命周期
- 默认 client 与 classified client 的建连、收发、选路
- 默认 node 与 classified node 的建连、收发、选路
- server 的接入、连接管理、发送语义
- 并发约束与已知实现风险
- 当前测试覆盖

相关代码:

- `src/cmd.rs`
- `src/client/client.rs`
- `src/client/classified_client.rs`
- `src/node/node.rs`
- `src/node/classified_node.rs`
- `src/server/server.rs`
- `src/server/peer_manager.rs`
- `tests/communication_matrix.rs`

## 2. 协议模型

所有消息都按 `header_len(u8) + CmdHeader + body` 发送,其中 `header_len` 是 `CmdHeader` 编码后的字节长度,因此 header 本身必须满足 `<= 255`。

`CmdHeader<LEN, CMD>` 包含:

- `pkg_len`: body 长度
- `version`: 协议版本
- `cmd_code`: 命令码
- `is_resp`: 是否响应包
- `seq`: 请求序号;仅请求-响应模式使用

协议的两层泛型边界分别负责:

- `LEN`: body 长度字段的编码类型,例如 `u16``u32`
- `CMD`: 命令码编码类型,例如 `u8``u16` 或自定义可编解码类型

## 3. Body 生命周期

`CmdBody` 既可以承载内存中的字节,也可以承载流式 reader。

接收侧真正关键的是 `CmdBodyRead`:

- 它把当前 tunnel 的读半边包装成一个限长 reader
- handler 读取多少,就推进多少偏移
- 如果 handler 没把 body 读完,`Drop` 会异步 drain 剩余字节
- body 被完整消费或 drain 完成后,读半边才会归还给接收循环

这个设计保证了“单条连接上连续读取多帧”不会因为上一个 handler 只读了部分 body 而错位。

## 4. Tunnel 抽象

底层传输通过三类 trait 抽象:

- `CmdTunnelRead<M>`
- `CmdTunnelWrite<M>`
- `CmdTunnelMeta`

读写半边都要提供:

- 本地 `PeerId`
- 远端 `PeerId`
- 可选 `tunnel_meta`

因此上层只需要关心命令收发与响应关联,不关心底层是 TCP、TLS 还是测试中的内存双工流。

## 5. Client 设计

### 5.1 默认 client

默认实现是 `DefaultCmdClient`。内部核心状态有三类:

- `CmdHandlerMap`
- `RespWaiter`
- `ClassifiedWorkerPool<TunnelId, CommonCmdSend, CmdWriteFactory>`

虽然底层用了 classified worker pool,但默认 client 只把 `TunnelId` 当作 worker key。`tunnel_count` 控制池子最多维持多少条 tunnel。

`CmdWriteFactory::create()` 会:

1. 调用用户提供的 `CmdTunnelFactory::create_tunnel()`
2. 为新 tunnel 分配 `tunnel_id`
3. 拆分读写半边
4. 启动该 tunnel 的后台接收任务
5. 封装成 `CommonCmdSend`

接收任务按如下流程循环:

1. 读取 `header_len`
2. 解码 `CmdHeader`
3. 构造限长 `CmdBody`
4. 若是响应包,则按 `gen_resp_id(tunnel_id, cmd, seq)` 投递给 `RespWaiter`
5. 若是普通请求,则查 `CmdHandlerMap` 执行 handler
6. handler 返回 `Some(CmdBody)` 时,自动沿原 `cmd/seq/version` 回包
7. 等待 body 被消费或 drain 完成,再继续读取下一帧

发送侧能力集中在 `CommonCmdSend`:

- `send` / `send_with_resp`
- `send_parts` / `send_parts_with_resp`
- `send_cmd` / `send_cmd_with_resp`
- 兼容别名 `send2*`,但已 deprecated,当前主接口是 `send_parts*`

同一条 tunnel 的 writer 通过 `ObjectHolder` 串行化,避免并发写导致 header/body 交叉。

`send*_with_resp` 还显式禁止在当前 tunnel 的接收任务里同步等待响应;若检测到调用方就是该 tunnel 的 recv task,会直接返回错误,避免自阻塞。

### 5.2 Classified client

`DefaultClassifiedCmdClient` 在收发模型上与默认 client 基本一致,差异主要是选路键变成:

`CmdClientTunnelClassification<C> { tunnel_id, classification }`

行为分成三类:

- 指定 `tunnel_id`:只命中已存在的精确 tunnel,不会为该 `tunnel_id` 新建 tunnel
- 指定 `classification`:从匹配分类的 tunnel 中取一个;若不存在,可由 factory 新建对应分类 tunnel
- 两者都不指定:退化为任意可用 tunnel

因此 classified client 本质上是在默认 client 之上增加“按分类建连/选路”的能力,没有引入新的协议语义。

### 5.3 精确 tunnel 获取语义

当前实现里,`client` 与 `node` 的“按指定 `tunnel_id` 精确获取”已经不再把“是否真实存在”的判断完全交给 `sfo-pool`。

原因是:

- `sfo-pool::get_classified_worker()` 只会在空闲 worker 列表里找匹配项
- 如果目标 tunnel 正被别的 guard 持有,而池子又还没满,pool 会倾向于走 `factory.create(...)`
-`client` / `node` 的“精确 tunnel 复用”场景来说,这会把“存在但 busy”误判成 `tunnel not found`

现在 `client` / `classified client` / `node` / `classified node` 都在上层维护了一份按真实 `tunnel_id` 索引的运行时表,状态只有两类:

- `Idle`
- `Borrowed`

具体语义如下:

- `get_send(..., tunnel_id)` / `send_by_specify_tunnel*`
  - 运行时表里没有该 `tunnel_id`:直接返回 `tunnel not found`
  - 运行时表里是 `Borrowed`:在库内部等待,不向上层误报错误
  - 运行时表里是 `Idle`:预占为 `Borrowed` 后再去从 pool 获取

- 通用获取路径
  - `get_send()`
  - `get_classified_send(...)`
  - `get_peer_classified_send(...)`
  - 这些路径在真正拿到 worker 后,会按实际 `tunnel_id` 把运行时表标成 `Borrowed`

- guard 释放
  - 若 worker 仍然存活:状态从 `Borrowed` 改回 `Idle`,唤醒等待者
  - 若 worker 已失效:从运行时表删除该 `tunnel_id`,等待者下一轮会看到 `tunnel not found`

- `clear_all_tunnel()`
  - 除了清空 pool,还会清空运行时表并唤醒等待者,避免挂住

因此,当前“按指定 tunnel 精确发送”的语义已经收敛为:

- 真实不存在:返回 `tunnel not found`
- 临时并发占用:等待归还

这套逻辑只影响“精确 tunnel 复用”路径,不改变按分类或按 peer 的扩容语义。

### 5.4 Node 说明

`DefaultCmdNode` 与 `DefaultClassifiedCmdNode` 的核心模式与 client 相同,只是选路维度从“本地维护的一组 tunnel”扩展成了:

- 默认 node:`(PeerId, Option<TunnelId>)`
- classified node:`CmdNodeTunnelClassification { peer_id, tunnel_id, classification }`

其中:

- 不指定 `tunnel_id` 的路径仍允许按 `peer_id` / `classification` 复用空闲 tunnel,必要时新建
- 指定 `tunnel_id` 的路径现在和 client 一样,遵循“存在但 busy 则等待,不存在才报错”的规则

这保证了 node 场景下:

- `send_by_specify_tunnel(...)`
- `send_by_specify_tunnel_with_resp(...)`
- `get_send(peer_id, tunnel_id)`

不再因为临时并发占用误删或误判某条活跃 tunnel。

## 6. Server 设计

server 由三层组成:

- `DefaultCmdServerIncoming`: accept loop
- `DefaultCmdServerService`: 连接管理、收包、发包、响应关联
- `DefaultCmdServer`: 对外组合对象

### 6.1 接入与连接管理

`DefaultCmdServerIncoming::run()` 持续从 `CmdTunnelListener` 接收新 tunnel,并将其交给 `DefaultCmdServerService::handle_tunnel()`。

`DefaultCmdServerService::serve_tunnel()` 会:

1. 为新连接分配 `tunnel_id`
2. 拆分读写半边
3. 启动该 tunnel 的接收任务
4. 封装成 `PeerConnection`
5. 交给 `PeerManager` 纳管

`PeerManager` 维护两份索引:

- `TunnelId -> PeerConnection`
- `PeerId -> Vec<TunnelId>`

并负责在:

- 某个 `peer_id` 的首条连接建立时触发 `on_peer_connected`
-`peer_id` 的最后一条连接断开时触发 `on_peer_disconnected`

### 6.2 收包与分发

server 每条 tunnel 同样有独立接收任务,流程和 client 基本对称:

- 响应包:按 `resp_id` 投递到 `RespWaiter`
- 普通请求:查 `CmdHandlerMap` 执行 handler
- handler 返回 `Some(CmdBody)` 时自动回包

server 额外维护了 `NamedStateHolder<tokio::task::Id>`,用于标记“当前 task 正在处理入站命令”。带响应的发送接口在发现自己正处于该状态时,会拒绝同步等待响应,避免典型死锁:

- 接收循环正在执行 handler
- handler 内再次发起 `send*_with_resp`
- 接收循环在 handler 返回前不会继续读取后续帧
- 响应无法被当前连接读到

因此,handler 内如果需要反向请求对端,应尽快 `spawn` 独立任务。

### 6.3 发送语义

server 当前暴露三类目标范围:

- `peer_id` 发送
-`tunnel_id` 精确发送
-`peer_id` 广播到全部 tunnel

当前具体语义如下:

- `send` / `send_parts` / `send_cmd`
  - 先取 `peer_id` 下的连接列表
  - 为空时返回 `PeerConnectionNotFound`
  - 否则按连接顺序串行 failover,首个成功即返回
  - 全部失败时返回最后一个错误

- `send_with_resp`
  - 同样按连接顺序尝试
  - 若当前 task 正在处理入站命令,会跳过该连接
  - 某个连接发送成功且收到响应后立即返回
  - 全部失败或都不可用时,返回通用 `Failed`

- `send_cmd` / `send_cmd_with_resp`
  - 当前实现已走流式 `tokio::io::copy`
  - 不会先把 `CmdBody` 完整读入内存
  - 大 body 的资源行为和 client 侧保持一致

- `send_by_specify_tunnel*`
  - 通过 `TunnelId` 直接查 `PeerManager`
  - tunnel 不存在时返回 `PeerConnectionNotFound`
  - 当前实现不会再校验该 tunnel 是否属于传入的 `peer_id`

- `send_by_all_tunnels` / `send_parts_by_all_tunnels`
  - 对当前 `peer_id` 下的每条 tunnel 逐个尝试
  - 单条 tunnel 失败只记日志,不影响整体返回
  - 即使没有任何 tunnel,也会返回 `Ok(())`
  - 语义是明确的 best-effort broadcast

## 7. 响应关联

client/server 共用 `gen_resp_id()` 生成 waiter key。

当前实现不是简单截断 `CMD` 编码,而是:

1. 先拿到 `CMD` 的原始编码字节
2. 将各个 8-byte chunk 混入一个 `u64` 指纹
3. 再与 `tunnel_id``seq` 拼成 `u128`

逻辑上可理解为:

- 高 32 bit:`tunnel_id`
- 中间 32 bit:`seq`
- 低 64 bit:`cmd` 编码的混合指纹

这保证了:

- 不同 tunnel 的响应不会串
- 同一 tunnel 内不同 `seq` 的响应不会串
- `CMD` 超过 8 字节时仍会参与混合,而不是只保留前/后 8 字节

仓库已有单元测试覆盖 `seq`、`tunnel_id`、不同命令码,以及长命令码尾部变化的区分能力。

## 8. 并发约束

- 同一条 tunnel 的读循环是串行的;一个 body 未完成消费前,不会进入下一帧。
- 不同 tunnel 之间天然并行;每条 tunnel 各自有独立接收任务。
- 同一条 tunnel 的写通过 `ObjectHolder` 串行化。
- `client` / `node` 的“按指定 tunnel 精确发送”在 tunnel 已被借出时会等待已有 guard 归还,而不是把 busy 误判成 not found。
- handler 可以只读取部分 body,但必须依赖 `CmdBodyRead` 的完成/自动 drain 机制恢复流状态。
- 带响应的同步发送不能在“当前入站命令处理 task”里直接等待,否则会被 client/server 的保护逻辑拒绝。

## 9. 当前测试覆盖

当前仓库已不再是“0 测试”状态。主要覆盖集中在 `tests/communication_matrix.rs`,并补充了 `src/client/mod.rs` 中的 `gen_resp_id` 单元测试。

已明确覆盖的场景包括:

- default client <-> server 双向请求/响应
- classified client <-> server 双向请求/响应
- default node / classified node 与 server 的互通
- client、classified client、server、node 的主要发送接口矩阵
- timeout 行为
- `client` / `classified client` 指定 tunnel 在 busy 状态下的等待语义
- `node` / `classified node` 指定 tunnel 在 busy 状态下的等待语义
- missing peer 时各发送接口的返回语义
- server 多 tunnel failover
- broadcast 的 best-effort 语义
- `send_cmd` / `send_cmd_with_resp` 的大 body 流式发送
- 长命令码参与 `resp_id` 计算

因此,文档中此前关于“未覆盖 failover / broadcast / 大 body / 长命令码”的结论已经不再成立。

## 10. 当前剩余风险

### 10.1 server 发送接口的错误语义仍不完全统一

`send` / `send_parts` / `send_cmd` 在无连接时返回 `PeerConnectionNotFound`,全部连接失败时返回最后一个错误;但 `send_with_resp` / `send_parts_with_resp` 在所有连接都失败或都被跳过时,最终返回的是较泛化的 `Failed`。

这会让调用方更难区分:

- 根本没有可用连接
- 某条连接写失败
- 因当前 task 处于 handler 上下文而被跳过

### 10.2 广播接口是 best-effort,但返回类型仍是普通 `CmdResult<()>`

`send_by_all_tunnels` 与 `send_parts_by_all_tunnels` 当前即使:

- `peer_id` 没有任何连接
- 某些连接甚至全部连接发送失败

也会返回 `Ok(())`,失败仅体现在日志里。

这个行为本身是明确的,但接口名和返回类型不会把“best-effort、不可感知部分失败”直接暴露给调用方。

### 10.3 指定 tunnel 的 server 接口没有校验 `peer_id`

`send_by_specify_tunnel*` / `send_cmd_by_specify_tunnel*` 当前只按 `TunnelId` 查连接,传入的 `peer_id` 主要用于日志和 API 形状对齐,不参与实际校验。

如果调用方手里持有某个有效 `tunnel_id`,即使给错 `peer_id`,发送仍会命中该 tunnel。

### 10.4 `tunnel_meta` 的来源在不同 client 实现间仍不一致

- 默认 client 在建 tunnel 时从 write half 读取 `tunnel_meta`
- classified client 在建 tunnel 时从 read half 读取 `tunnel_meta`

如果某种 tunnel 实现的 read/write half 暴露出的 meta 不完全对称,调用方可见行为会不一致。

### 10.5 运行时 tunnel 表是进程内状态,不是持久注册表

`client` / `node` 当前依赖一份运行期维护的 `tunnel_id -> { Idle | Borrowed }` 表来区分“busy”与“not found”。

这意味着:

- 它只描述当前实例在本进程内已经见过并成功借出过的 tunnel
- 它不是独立于 pool/连接生命周期的持久真值源
- 如果后续再引入跨实例共享、外部恢复或更复杂的迁移语义,需要重新定义这张表和底层连接状态的同步方式

## 11. 建议

后续如果要继续收敛语义,优先级建议如下:

1. 统一 server 各发送接口在“无连接 / 跳过 / 写失败 / 超时”下的错误分类。
2. 明确 broadcast 是否要保持 best-effort;若保持,最好在命名或返回类型上体现。
3.`send_by_specify_tunnel*` 增加 `peer_id` 一致性校验,或在 API 文档里明确说明忽略 `peer_id`4. 统一 `tunnel_meta` 的权威来源。
5. 如果后续还要把“存在但 busy 则等待”的语义下沉到公共层,应考虑为 `sfo-pool` 增加新策略或新 API,而不是直接改掉现有 `get_classified_worker()` 的扩容语义。