Files
lucky-cat/docs/knowledge vault/bugfix-downstream-stall-recovery.zh-CN.md
T

11 KiB
Raw Blame History

下行音频卡死后的会话恢复:问题原因与修复方案

1. 对应提交

a94df95 fix(multimodal): recover stalled downstream sessions

本文说明云端 TTS 下行音频在播放过程中长时间不再推进时,设备可能一直停留在“云端正在响应”的状态,导致后续无法唤醒或开始下一轮对话的问题。

2. 用户可见现象

典型过程如下:

  1. 设备已经成功唤醒并发送用户语音;
  2. 云端开始返回 TTS,设备也开始播放;
  3. 播放中途音频包停止到达,或播放器长时间没有进展;
  4. 音频管线的保护逻辑终止本地播放;
  5. 设备表面上不再播放,但云端会话仍可能保持 responding 或 active;
  6. 后续再次说唤醒词或按键,无法顺利开始新会话;
  7. 断开连接或重启设备后才恢复。

这个问题和“偶尔停顿后继续播放”不同。短暂网络抖动可以通过预缓冲或重缓冲吸收;这里处理的是已经超过保护阈值、被判定为不可继续等待的长时间 stall。

3. 原有逻辑的问题

3.1 stall 被当作普通 stopped

修复前,音频管线检测到下行播放长期无进展后,会推进 playback generation、停止播放任务并通知:

MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STOPPED

但 STOPPED 本身可能代表多种正常情况:

  • 用户主动打断;
  • 上层主动停止;
  • 会话正常切换;
  • transport 关闭导致播放结束;
  • 播放器因长时间无数据被保护逻辑强制终止。

把 stall 和正常 stopped 混在一起后,上层无法判断是否需要恢复云端会话。

3.2 本地播放器停止不等于云端任务结束

播放器只负责消费并播放已经收到的 Opus 数据。播放器停止后,服务端并不会自动知道设备已经放弃本次响应。

因此可能形成状态分裂:

设备音频管线:已经停止播放
control-plane:仍认为任务存在
云端:仍认为本轮响应或任务未结束
WebSocket:物理连接可能仍显示 connected

此时“WebSocket 还连着”并不代表会话可复用。旧任务没有收尾,新一轮 RequestToSpeak、Start 或唤醒流程可能被旧状态阻塞。

3.3 直接重连会造成不必要的恢复开销

一旦 stall 就立刻关闭 WebSocket 虽然能清空状态,但会带来:

  • 重新鉴权和建连延迟;
  • 额外网络请求;
  • 正常可用的 WebSocket 被浪费;
  • 偶发的服务端响应异常被放大为完整断线。

因此需要先尝试只结束当前任务,失败后再退休连接。

4. 根本原因

问题的根本原因是下行播放状态和云端会话状态之间缺少针对 stall 的闭环:

下行长时间没有新数据或播放进展
        ↓
音频保护逻辑停止本地播放器
        ↓
仅上报普通 STOPPED
        ↓
上层无法识别这是异常卡死
        ↓
没有向云端结束当前任务,也没有退休 transport
        ↓
旧会话长期保持 active,后续唤醒被阻塞

5. 修复方案

修复采用“两级恢复”,优先保留 WebSocket,无法收敛时再重连。

5.1 增加独立的 STALLED 事件

音频管线在保护 watchdog 判定响应卡死时,不再发送普通 STOPPED,而是发送:

MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STALLED

该事件逐层传递:

audio pipeline
    ↓
platform callback
    ↓
domain event
    ↓
control-plane session service
    ↓
CONTROL_PLANE_MULTIMODAL_EVENT_DOWNSTREAM_STALLED

这样上层可以准确区分:

事件 含义 是否启动异常恢复
STOPPED 主动停止、打断或普通停止 否
COMPLETED 正常播放完成 否
STALLED 下行响应长期无进展,被保护机制终止 是

5.2 只对云端响应启动会话恢复

session service 收到 STALLED 后,会检查当前是否确实存在云端响应:

  • 当前有有效 task id;
  • 并且云端仍处于 responding,或者本轮已经收到过云端音频。

如果只是本地提示音卡住,不会修改云端 WebSocket 状态,避免本地素材问题误伤云端连接。

5.3 第一级恢复:请求服务端 Stop

确认是云端下行响应 stall 后:

  1. 停止音频上行;
  2. 设置 s_downstream_stall_stop_pending;
  3. 停止继续接收和播放本轮迟到音频;
  4. 发送 finish-task / Stop 指令;
  5. 最多等待 2000 ms 的服务端确认。

如果收到服务端 Stopped:

  • 重置当前 session state;
  • 停止残留的本地下行播放;
  • 通知上层服务端已经不再 listening;
  • 清除 stall recovery 状态;
  • 保留当前 WebSocket,供下一轮对话复用。

这是成本最低的恢复路径。

5.4 第二级恢复:退休 transport 并重新连接

以下情况会进入第二级恢复:

  • Stop 指令发送失败;
  • 发送 Stop 后 2000 ms 内没有收到服务端确认;
  • 当前连接已经断开。

对于前两种情况,旧 transport 会被标记为:

CONTROL_PLANE_TRANSPORT_LOSS_DOWNSTREAM_STALL

随后执行:

清除 stall recovery 状态
    ↓
将旧 transport 标记为不可用
    ↓
停止旧 WebSocket
    ↓
调度新的 WebSocket 启动
    ↓
重新进入鉴权和建连流程

这样即使服务端任务已经卡死、无法响应 Stop,设备也不会无限等待。

5.5 watchdog 负责有界等待

恢复流程由已有多模态 watchdog 推进,而不是在音频回调里阻塞等待。

内部记录三个状态:

s_downstream_stall_recovery_pending
s_downstream_stall_stop_sent
s_downstream_stall_recovery_started_ms

它们分别表示:

  • 是否存在待处理的 stall 恢复;
  • Stop 是否已经发出;
  • 等待 Stop 确认的起始时间。

这样音频线程只负责报告事实,控制层异步完成网络操作和超时处理,避免阻塞播放任务或 WebSocket 回调。

5.6 启停和连接异常时清理恢复状态

以下场景会主动清除 stall recovery 状态:

  • transport disconnected;
  • transport error;
  • task failed;
  • 多模态客户端重新启动;
  • 多模态客户端停止。

目的是避免旧会话的 stall 标记污染下一条连接或下一轮任务。

6. 修复后的状态流转

6.1 服务端正常确认 Stop

检测到下行 stall
    ↓
上报 STALLED
    ↓
确认是云端响应
    ↓
发送 Stop
    ↓
服务端返回 Stopped(不超过 2 秒)
    ↓
清理当前任务状态
    ↓
保留 WebSocket
    ↓
下一轮可以继续使用

6.2 Stop 失败或确认超时

检测到下行 stall
    ↓
发送 Stop 失败,或等待超过 2 秒
    ↓
将旧 transport 标记为 downstream_stall
    ↓
关闭旧 WebSocket
    ↓
调度重新鉴权和建连
    ↓
恢复到可接受新会话的状态

6.3 本地提示音 stall

检测到本地播放 stall
    ↓
没有有效云端响应
    ↓
仅结束本地播放
    ↓
不发送云端 Stop
    ↓
不关闭 WebSocket

7. 为什么等待时间是 2 秒

CONTROL_PLANE_DOWNSTREAM_STALL_STOP_GRACE_MS 当前为 2000 ms。

它不是播放提示音或采集音频的固定延迟,而只是 stall 已经发生后,等待服务端确认 Stop 的最长保护时间。

选择有界等待的原因是:

  • 完全不等待会导致每次 stall 都立刻重连;
  • 无限等待会让设备再次卡死;
  • 2 秒给正常的服务端控制消息留出响应时间;
  • 超时后仍能保证设备最终自愈。

在这 2 秒内,当前异常会话不会被当作正常会话继续使用。

8. 修复的影响范围

会改变的行为

  • 云端 TTS 被确认 stall 后,会主动结束当前任务;
  • Stop 无法确认时会关闭并重建 WebSocket;
  • stall 恢复期间会停止音频上行并丢弃当前响应的迟到音频;
  • 最坏情况下,下一轮对话需要等待旧连接退休和新连接建立。

不会改变的行为

  • 正常 TTS 播放完成不会触发恢复;
  • 用户主动打断产生的普通 stopped 不会误触发 stall 重连;
  • 本地提示音 stall 不会主动关闭云端连接;
  • 该提交不负责修复 Opus Decoder 内存泄漏;
  • 该提交不负责 Wi-Fi STA 重新关联路由器;
  • 该提交不解决短时抖动导致的普通卡顿,短时抖动由缓冲策略处理。

9. 关键日志

正常进入恢复时:

Cloud downstream playback stalled; request bounded session recovery
Downstream stall recovery queued for watchdog worker
Downstream stall recovery started: request Stop and wait up to 2000 ms
Request Stop for stalled downstream response

服务端及时确认时:

Downstream stall Stop acknowledged; transport remains reusable
Downstream stall recovered by server Stop acknowledgement; keep WebSocket

Stop 发送失败时:

Downstream stall Stop request failed: ...; retire transport now

等待超时时:

Downstream stall Stop acknowledgement timed out after 2000 ms; retire transport

如果进入 transport 退休,还应继续看到重新调度 WebSocket 的日志。

10. 建议验证方法

10.1 正常流程回归

  1. 连续完成至少 20 轮正常对话;
  2. 验证正常 TTS 完成不会出现 DOWNSTREAM_STALLED;
  3. 验证用户按键打断和唤醒打断不会触发不必要的 WebSocket 重连。

10.2 stall 后服务端能够确认 Stop

  1. 人为阻断下行音频数据,但保持 WebSocket 控制消息可用;
  2. 等待本地 watchdog 判断 stall;
  3. 确认设备发送 Stop;
  4. 确认收到 Stopped 后保留原 WebSocket;
  5. 立即开始下一轮唤醒,确认可以正常对话。

10.3 stall 后服务端不确认 Stop

  1. 人为阻断服务端响应或所有 WebSocket 数据;
  2. 确认设备等待不超过 2000 ms;
  3. 确认旧 transport 被退休;
  4. 恢复网络后确认设备自动建立新连接;
  5. 确认无需重启设备即可再次唤醒。

10.4 本地提示音回归

  1. 播放本地唤醒提示音或系统提示音;
  2. 人为制造本地播放异常;
  3. 确认日志显示本地 stall;
  4. 确认没有发送云端 Stop,也没有关闭健康的 WebSocket。

11. 涉及文件

components/control_plane/internal/control_plane_internal.h
components/control_plane/src/multimodal/control_plane_multimodal_runtime.c
components/control_plane/src/multimodal/control_plane_multimodal_session_service.c
components/domain/include/domain.h
components/domain/src/runtime/domain_runtime_adapters.c
components/platform/include/platform.h
components/platform/internal/multimodal/audio_pipeline.h
components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c
components/platform/src/runtime/platform_multimodal_runtime.c