第 3 章 进程间通信:Unix socket 上的 JSON-RPC 契约

本章代码走读:duck-ipc-proto/src/lib.rs(约 6200 行)——Id、Call、Service 三个类型撑起整个通信体系。

3.1 一个 crate 防一种死法

duck-ipc-proto 的存在理由写在 Call 枚举的文档注释里:

/// A method together with its parameters.
///
/// Every request is built from one of these and read back as one, so a method can never be
/// paired with another method's parameters — the drift this crate exists to prevent.
#[derive(Debug, Clone, PartialEq)]
pub enum Call {
    /// Version handshake. The first call on a connection.
    Hello(HelloParams),
    ...

方法与参数在类型层面绑定:不存在"方法名字符串对了、参数结构旧了"的请求。这是"契约即代码"的 Rust 表达——协议演化时,编译器替你找出所有没跟上的人。

3.2 代码走读:Call 枚举——整个系统的 API 表

约 100 个变体按命名空间分组,注释里直接标注了每个调用的节奏类型(这对理解系统时序至关重要):

    // ── intents ──────────────────────────────────────────────────────────────
    /// Continuous. Send as a notification.
    RobotMove(MoveParams),
    /// Continuous. Send as a notification.
    RobotHead(HeadParams),
    /// Discrete. Send as a request; the answer is [`LookResult`].
    RobotLook(LookParams),
    RobotStop,
    RobotEnable(EnableParams),
    /// Power the joints and ramp to the home pose. No policy needed.
    RobotInit,
    /// Cut power to the joints. The robot collapses if nothing holds it.
    RobotRelax,
    /// Reboot servos (all of them, or the ids named), then limp. See [`method::ROBOT_REBOOT_MOTORS`].
    RobotRebootMotors(RebootMotorsParams),
    /// Run a one-shot skill, or toggle sit↔stand.
    RobotDo(DoParams),
    /// Standing body pose. Continuous. Send as a notification.
    RobotPose(PoseParams),
    /// Mouth opening. Continuous. Send as a notification.
    RobotMouth(MouthParams),

连续调用是通知(notification),离散调用才是请求(request)——50 Hz 的遥操作指令走 fire-and-forget,丢一帧无所谓(下一帧 20 ms 后就到);配对、安装、配置这类低频高语义操作才需要应答。这个区分直接决定了 Id 的设计:

/// Request identifier. `None` on a [`Request`] makes it a notification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Id {
    Number(u64),
    Text(String),
}

命名空间的完整清单(每组的语义):

命名空间 职责 代表调用
update.* 组件更新与回滚 Check Apply Rollback ResetToGolden Select Pin Subscribe
robot.* 运动意图与生命周期 RobotMove RobotDo RobotInit RobotRelax RobotPolicies
policy.* 策略库管理 PolicyCheck PolicyInstall PolicyFetch PolicySearch
detector.* 视觉检测模型 DetectorCheck DetectorInstall
account.* 设备账号 AccountLogin AccountStatus AccountLogout
net.* Wi-Fi NetStatus NetScan NetConnect NetForget
system.* 系统信息与安全 SystemInfo SystemLogs SystemPairingPin SystemAuthenticate
pad.* 手柄 PadPair PadBindings PadInput
流订阅 传感器/媒体流 TofStream HeadImuStream

注意 TofStream 的注释:"Subscribe to the ToF depth stream. Answered by tofd."——谁应答写在契约里,路由不是猜的。

3.3 代码走读:Service 枚举——没有中间人的路由表

/// The service that owns the answer to a call.
///
/// One socket per service, connected directly — there is no broker (`architecture.md` §2.2). A
/// transport adapter holds connections to the services whose calls it carries, and to no others:
/// `btd` holds three, and `padd` being absent from them is deliberate rather than incidental —
/// `padd` is the unprivileged client whose whole value is having no special access.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Service {
    /// `updaterd`, at [`DEFAULT_SOCKET`].
    ...

三个决策一目了然:

  1. 每服务一个 socket,直连,无 broker。没有 DBus、没有消息中间件——每少一个组件就少一类故障与一个依赖。
  2. 传输适配器只持有它需要的连接。最小权限不是口号:btd(蓝牙桥)只连三个服务。
  3. padd 的"无特权"是被设计出来的。手柄服务故意不持有任何特殊连接——它的全部价值就是"没有任何特殊访问"。攻击面管理进了类型系统。

3.4 代码走读:配对 PIN——一条无法按 BLE 规格实现的需求

配对认证是这个系统里最精巧的一段协议设计。SystemPairingPin:

    /// Read the pairing PIN.
    ///
    /// Exists so `btd` can answer a BLE passkey request without owning config. It must never be
    /// routed to BLE — a PIN an unpaired peer can read authorises nothing — and `btd`'s routing
    /// table has a test saying so.
    SystemPairingPin,

SystemAuthenticate:

    /// Prove knowledge of the robot's pairing PIN.
    ///
    /// Answered by the **transport** rather than by any service, which makes it unlike every
    /// other call here. BLE cannot express a fixed, printed-on-the-robot passkey — the spec has
    /// the *displaying* side generate a random one, and a headless robot can display nothing — so
    /// the PIN check moved from the link layer to this one, where we define the rules. See
    /// `docs/design/app-path-design.md` §5.
    SystemAuthenticate(AuthenticateParams),

拆开看:BLE 规格假定配对双方至少一方有屏幕(显示侧生成随机码);一台无头机器人既不能显示也不能遵循该模型,于是团队把 PIN 校验从链路层上移到应用协议层,并配套两条纪律——PIN 读取调用永不被路由回 BLE(否则未配对端读走 PIN,认证形同虚设),且这条禁令由 btd 路由表上的一个测试看守。又是第 2 章那招:"两处必须同步/禁止的事,用机制不用记忆。"

3.5 鸭群合唱:通知的三方协作

chorale(合唱)功能让多只鸭子互相听见、同步鸣叫,它的协议是三个调用的小型状态机,注释把方向讲得极清楚:

    /// `btd` subscribing to what it should advertise. Answered, then a stream of
    /// [`method::CHORALE_BEACON`] notifications.
    ChoraleSubscribe,
    /// `robotd` telling `btd` what to advertise. A notification.
    ChoraleBeaconSet(ChoraleAdvertise),
    /// `btd` telling `robotd` what it heard. A notification.
    ChoraleHeard(ChoraleHeard),

robotd 决定广播什么 → btd 通过无线电发出去并听别的鸭子 → 听到的内容回流给 robotd。感知-决策-通信的闭环全部走同一套 JSON-RPC 通知机制,没有为"多机协作"新造任何基础设施。

3.6 为什么不是 gRPC / DDS

v0.1 讨论过候选方案的表格,现在可以补上实证:Call 枚举里连续调用是通知、Service 直连无 broker、PIN 认证在传输层应答——这三个需求在 gRPC/DDS 里都要绕着框架做。而 6200 行的 duck-ipc-proto 用纯类型就把"方法-参数绑定、服务归属、节奏类型、路由禁区"全部编码完毕,serde 序列化后即为线上格式,jq/nc 即可调试。当你的 IPC 语义已经复杂到需要"契约"时,把契约做成类型比做成配置更有利。

3.7 本章小结

duck-ipc-proto 用三个枚举(Id、Call、Service)回答了 IPC 的全部四个问题:谁能被调用(方法表)、带什么参数(类型绑定)、谁应答(服务归属)、以什么节奏(通知 vs 请求)。第 4 章进入这些调用的最大消费者——robotd 的 50 Hz 控制循环。