[Bug] 移动端镜像的流式回复出现中间断口:实时 push 帧丢失且无序号可检测
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 问题描述 / What happened
移动端镜像被控端会话时,流式生成中的回复文本会**在中间凭空缺失若干连续片段**,且缺口无任何提示。turn 结束后重新进入会话,内容恢复完整。
对齐同一 turn 的两端截图(Mac 为被控端权威内容,iPhone 为控制端镜像):
| 位置 | Mac(权威) | iPhone(镜像) |
|---|---|---|
| 开头 | `Cindy 灰度发布回归方案` / `一、背景与目标` / `Cindy 作为面向开发…不同网络条件和`**`不同插件配置。这类差异会使…关键窗口。`** | **`Cindy 灰度`** ⟶ **`不同插件配置。这类差异会使…关键窗口。`** |
| 段二 | `…但只要变更触及`**`任务执行、用户数据、权限判定、`**`外部服务调用、模型选择或自动化操作` | `…但只要变更触及` ⟶ `外部服务调用、模型选择或自` |
注意开头的 `Cindy 灰度` **是保留的**,随后中间一整块消失 —— 是**多处中间断口**,不是"只剩尾部"。这排除了整行被替换/丢弃类原因,指向逐帧 delta 丢失。
期望行为:镜像端的流式文本应与被控端逐字一致;若确实发生丢帧,至少要能被检测并自动补齐,而不是静默留洞。
### 根因
assistant 文本 delta 以**增量** `maker:event` push 帧逐帧上链(`packages/maker-core/src/agents/claude-code/translator.ts:1116-1127`,每帧只带新增子串 `delta.text` + `isFinal: false`),而 push 通道是**无序号、无 ack、无缓冲的 fire-and-forget**:
1. **不在线即静默丢弃**(`packages/device-link/src/client.ts:449-453`):
```ts
/** 被控端:广播转发 push 帧(fire-and-forget;失败由上层缓冲策略兜底) */
sendPush(dst: string, channel: string, payload: unknown): void {
if (this.status !== 'online') return;
```
注释声称"失败由上层缓冲策略兜底",但**该上层缓冲并不存在**:`sendPushBestEffort`(`apps/desktop/src/main/device-link/dispatch.ts:468-494`)对任何发送失败只 `log.warn` 后 return;`client.ts` / `dispatch.ts` 中没有任何 outbound 队列、重发或 push 重放(唯一的 replay 是 `sessions` 列表级活动状态,`dispatch.ts:352`,不含消息内容)。
2. **push 信封没有序号**(`cindy-protocol/packages/device-link-protocol/src/protocol.ts:47-58`):`id` 仅用于 `invoke` / `link-open` 的 req/resp 配对,`push` 帧不带 seq。因此接收端**无法感知丢帧**,也没有任何 dirty 信号触发重拉(`local-db:session:error-persisted` 那类信号只覆盖 error 行)。
3. 移动端 `applyRemoteTextEvent`(`apps/mobile/src/session/remoteSessionStore.ts:749`)对非 final 帧做纯追加 `currentText + text`,收到什么就接什么 → 丢掉的帧在文本里留下一个永久的连续空洞,直到 turn 结束 `local-db:messages:created` 推权威整行(`apps/desktop/src/main/localDb/ipc/messages.ts:517`)整体覆盖才自愈。
复现环境中 iPhone 处于**飞行模式 + WiFi**(见截图状态栏),链路易产生亚秒级 `status !== 'online'` 窗口;每个窗口内发出的 delta 帧被 `client.ts:451` 丢弃,对应文本里的一个断口。
桌面端不受影响:本地 renderer 直接消费进程内事件,不经 device-link 这一跳。
### 已排除
- **超限截断**:本次输出约 3000 字,远低于 `REMOTE_PUSH_TEXT_BUDGET_CHARS = 160_000`(`dispatch.ts:154`);且截断路径会打 `__deviceLinkTruncated` 标记并**削尾部**,不会在中间开洞。
- **移动端合批丢失**:`enqueueRemoteTextDelta`(`remoteSessionStore.ts:793-799`)是拼接式合批(非 latest-wins),且所有其他事件路径都会先 flush,本地无损。
- **`persistId` 中途切换**:切换前会先 flush 旧批次(`remoteSessionStore.ts:788-790`),不会跨块串味。
## 环境 / Environment
- Cindy 版本或 commit / version or commit:`cb343234`
- 平台与版本 / platform & OS version:iOS 控制端(飞行模式 + WiFi)镜像 macOS 被控端;Win / Mac / iOS 三端同时发起同一提示词
- 安装方式 / install method:本地开发构建
## 复现步骤 / Steps to reproduce
1. iOS 控制端与 Mac 被控端同账号互联,进入同一会话。
2. 在被控端会话发起长输出、无工具调用的提示词(拉长流式窗口、增加帧数):
```
请不要调用工具。请一次性撰写一份不少于 3000 个中文字符的《Cindy 灰度发布回归方案》。必须依次包含:一、背景与目标;二、按 1-7 编号的执行步骤,每步至少两句;三、一个 5 列、至少 5 行的 Markdown 表格;四、一个不少于 20 行的 TypeScript 代码块;五、风险与回滚方案。不要省略任何章节,不要用"内容同上"或占位文本。
```
3. 让 iOS 端处于弱网(飞行模式 + WiFi,或生成过程中反复短暂切网),全程停留在会话页观察流式输出。
4. 生成结束前,把 iPhone 上的文本与 Mac 上的同一段逐字对齐 → 可见多处中间连续片段缺失。
5. turn 结束后重新进入会话 → 内容恢复完整。
## 日志与截图 / Logs & screenshots
> 截图待补:① Mac 被控端权威内容(含高亮段);② iPhone 控制端镜像,同一 turn,可见 `Cindy 灰度` 后直接跳到 `不同插件配置。`
排查提示:被控端日志中,发送抛错的丢帧会留下 `forwardPush to failed (maker:event): ...` 的 `log.warn`(`dispatch.ts:475` / `:481`);但 `status !== 'online'` 这条最常见的丢弃路径**连日志都没有**(`client.ts:451` 直接 return),排查时需注意这一盲区。
## 修复方向(供讨论,未实现)
1. **给流式 push 加序号 + 接收端缺口检测**:在 `push` 信封或 `maker:event` payload 里加 per-session 单调递增 seq;移动端发现 seq 跳号即标记该会话 dirty 并触发一次权威重拉(复用 `rebuildSessionSnapshot`),把"静默留洞"变成"可检测可自愈"。
2. **补上注释里承诺的发送侧缓冲**:`client.ts:449` 的"上层缓冲策略"目前不存在。可在被控端为 `maker:event` 维护小容量 ring buffer,`ws-online` 恢复后按序补发;或至少在 `status !== 'online'` 丢帧时打点,供后续对账。
3. **兜底自愈**:turn 内周期性(或在检测到丢帧后)用当前累积全文做一次 snapshot 覆盖,而不是只依赖 turn 末尾的 `local-db:messages:created`。
4. 至少让 `status !== 'online'` 的丢弃路径可观测(计数或 warn),消除排查盲区。
---
## 附:一处待验证的相关线索(**无现场证据**,不单独开 Issue)
分析本问题时,在移动端镜像的整窗刷新路径上发现另一处**可能**导致同类症状的代码缺陷。它与本 Issue 的截图证据**签名不符**(下详),也**没有任何真机观测或日志证据**,仅有代码层的构造性复现,因此只作线索记录在此,需先独立验证再决定是否处理。
`setLatestMessageWindow`(`apps/mobile/src/session/remoteSessionStore.ts:1130`)在 turn 中途整窗刷新时会**整行丢弃在飞 assistant 行**:保留判定(`:1169-1179`)只看 `createdAt` 是否落在窗口区间之外,而在飞行尚未落库(要到 `done` 边界才落,`apps/desktop/src/main/messagePersistBroadcaster.ts:961`)。thinking block 每块结束即刻落库(`messagePersistBroadcaster.ts:509-520`,`createdAt = Date.now()`),于是窗口里可能存在比在飞行更新的行 → 在飞行两个条件都不满足被丢弃,下个 delta 因 `:719` 按 clientId 查不到 existing 而从零重建。
保护缺口:`hasLiveAssistantMessage` 守卫只写在**空窗口**分支(`:1140`),非空分支缺失;`:1182-1204` 的兜底只覆盖 `mobile-stream-*` 生成 id(无 persistId 场景),而 `onAssistantTextEvent`(`messagePersistBroadcaster.ts:942-950`)对每个 delta 都返回 persistId,正常链路恰好落在无保护区间。
**证据强度**:仅代码层构造性复现(在单元测试里人为调用 `setLatestMessageWindow` 传入含更新行的窗口),可稳定得到"assistant 行整行消失、下个 delta 后只剩尾部"。**但**:
- 该签名是「**前缀全丢、只剩尾部**」,与本 Issue 观测到的「**中间断口、前缀保留**」不同,因此本次截图不是它造成的。
- 尚未验证真机上 turn 中途是否真的会触发整窗刷新(`apps/mobile/src/device-link/DeviceLinkContext.tsx:736` 的重连快照、`apps/mobile/app/sessions/[sessionId].tsx:2722` 的会话页重开),也没有对应现场日志。
若后续验证成立,修复方向是把 `:1140` 的 `hasLiveAssistantMessage` 守卫扩展到非空窗口分支。另需注意在飞行的 `createdAt` 取自**手机本地时钟**(`:732`、`:765`),而其他行来自被控端时钟,`normalizeMessages`(`:323-325`)纯按字符串排序 → 跨设备时钟偏斜时还可能导致气泡错序(同样未观测到)。
Contributor guide
Research direction
Start by reproducing the weak-network case, then read packages/device-link/src/client.ts, apps/desktop/src/main/device-link/dispatch.ts, cindy-protocol/packages/device-link-protocol/src/protocol.ts, and apps/mobile/src/session/remoteSessionStore.ts. Trace how maker:event deltas are sent and appended, including the cited line ranges and existing snapshot recovery. Done means the mobile mirror cannot silently retain missing middle text: losses are detectable and the authoritative content is recovered or retransmitted.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- distributed-systems, mobile-dev, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100