11 KiB
下行音频卡死后的会话恢复:问题原因与修复方案
1. 对应提交
a94df95 fix(multimodal): recover stalled downstream sessions
本文说明云端 TTS 下行音频在播放过程中长时间不再推进时,设备可能一直停留在“云端正在响应”的状态,导致后续无法唤醒或开始下一轮对话的问题。
2. 用户可见现象
典型过程如下:
- 设备已经成功唤醒并发送用户语音;
- 云端开始返回 TTS,设备也开始播放;
- 播放中途音频包停止到达,或播放器长时间没有进展;
- 音频管线的保护逻辑终止本地播放;
- 设备表面上不再播放,但云端会话仍可能保持 responding 或 active;
- 后续再次说唤醒词或按键,无法顺利开始新会话;
- 断开连接或重启设备后才恢复。
这个问题和“偶尔停顿后继续播放”不同。短暂网络抖动可以通过预缓冲或重缓冲吸收;这里处理的是已经超过保护阈值、被判定为不可继续等待的长时间 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 后:
- 停止音频上行;
- 设置
s_downstream_stall_stop_pending; - 停止继续接收和播放本轮迟到音频;
- 发送
finish-task / Stop指令; - 最多等待 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 正常流程回归
- 连续完成至少 20 轮正常对话;
- 验证正常 TTS 完成不会出现
DOWNSTREAM_STALLED; - 验证用户按键打断和唤醒打断不会触发不必要的 WebSocket 重连。
10.2 stall 后服务端能够确认 Stop
- 人为阻断下行音频数据,但保持 WebSocket 控制消息可用;
- 等待本地 watchdog 判断 stall;
- 确认设备发送 Stop;
- 确认收到
Stopped后保留原 WebSocket; - 立即开始下一轮唤醒,确认可以正常对话。
10.3 stall 后服务端不确认 Stop
- 人为阻断服务端响应或所有 WebSocket 数据;
- 确认设备等待不超过 2000 ms;
- 确认旧 transport 被退休;
- 恢复网络后确认设备自动建立新连接;
- 确认无需重启设备即可再次唤醒。
10.4 本地提示音回归
- 播放本地唤醒提示音或系统提示音;
- 人为制造本地播放异常;
- 确认日志显示本地 stall;
- 确认没有发送云端 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