# 下行音频卡死后的会话恢复:问题原因与修复方案 ## 1. 对应提交 ```text 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、停止播放任务并通知: ```text MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STOPPED ``` 但 `STOPPED` 本身可能代表多种正常情况: - 用户主动打断; - 上层主动停止; - 会话正常切换; - transport 关闭导致播放结束; - 播放器因长时间无数据被保护逻辑强制终止。 把 stall 和正常 stopped 混在一起后,上层无法判断是否需要恢复云端会话。 ### 3.2 本地播放器停止不等于云端任务结束 播放器只负责消费并播放已经收到的 Opus 数据。播放器停止后,服务端并不会自动知道设备已经放弃本次响应。 因此可能形成状态分裂: ```text 设备音频管线:已经停止播放 control-plane:仍认为任务存在 云端:仍认为本轮响应或任务未结束 WebSocket:物理连接可能仍显示 connected ``` 此时“WebSocket 还连着”并不代表会话可复用。旧任务没有收尾,新一轮 RequestToSpeak、Start 或唤醒流程可能被旧状态阻塞。 ### 3.3 直接重连会造成不必要的恢复开销 一旦 stall 就立刻关闭 WebSocket 虽然能清空状态,但会带来: - 重新鉴权和建连延迟; - 额外网络请求; - 正常可用的 WebSocket 被浪费; - 偶发的服务端响应异常被放大为完整断线。 因此需要先尝试只结束当前任务,失败后再退休连接。 ## 4. 根本原因 问题的根本原因是下行播放状态和云端会话状态之间缺少针对 stall 的闭环: ```text 下行长时间没有新数据或播放进展 ↓ 音频保护逻辑停止本地播放器 ↓ 仅上报普通 STOPPED ↓ 上层无法识别这是异常卡死 ↓ 没有向云端结束当前任务,也没有退休 transport ↓ 旧会话长期保持 active,后续唤醒被阻塞 ``` ## 5. 修复方案 修复采用“两级恢复”,优先保留 WebSocket,无法收敛时再重连。 ### 5.1 增加独立的 STALLED 事件 音频管线在保护 watchdog 判定响应卡死时,不再发送普通 `STOPPED`,而是发送: ```text MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STALLED ``` 该事件逐层传递: ```text 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 会被标记为: ```text CONTROL_PLANE_TRANSPORT_LOSS_DOWNSTREAM_STALL ``` 随后执行: ```text 清除 stall recovery 状态 ↓ 将旧 transport 标记为不可用 ↓ 停止旧 WebSocket ↓ 调度新的 WebSocket 启动 ↓ 重新进入鉴权和建连流程 ``` 这样即使服务端任务已经卡死、无法响应 Stop,设备也不会无限等待。 ### 5.5 watchdog 负责有界等待 恢复流程由已有多模态 watchdog 推进,而不是在音频回调里阻塞等待。 内部记录三个状态: ```text 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 ```text 检测到下行 stall ↓ 上报 STALLED ↓ 确认是云端响应 ↓ 发送 Stop ↓ 服务端返回 Stopped(不超过 2 秒) ↓ 清理当前任务状态 ↓ 保留 WebSocket ↓ 下一轮可以继续使用 ``` ### 6.2 Stop 失败或确认超时 ```text 检测到下行 stall ↓ 发送 Stop 失败,或等待超过 2 秒 ↓ 将旧 transport 标记为 downstream_stall ↓ 关闭旧 WebSocket ↓ 调度重新鉴权和建连 ↓ 恢复到可接受新会话的状态 ``` ### 6.3 本地提示音 stall ```text 检测到本地播放 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. 关键日志 正常进入恢复时: ```text 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 ``` 服务端及时确认时: ```text Downstream stall Stop acknowledged; transport remains reusable Downstream stall recovered by server Stop acknowledgement; keep WebSocket ``` Stop 发送失败时: ```text Downstream stall Stop request failed: ...; retire transport now ``` 等待超时时: ```text 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. 涉及文件 ```text 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 ```