Skip to content

[Bug] 提示音效的 AudioContext 从不释放:coreaudiod 常驻 ~6% CPU 并全程持有 PreventUserIdleSystemSleep #837

Description

@btiger

概述

通知音效的 AudioContext 只创建、从不释放。一旦用户开启音效并在窗口里点过一下,扬声器输出流就被占住直到 codeg 退出:coreaudiod 常驻 6% 左右 CPU,并且全程持有 PreventUserIdleSystemSleep —— 系统无法空闲睡眠、音频硬件也无法进入低功耗。对笔记本续航是持续性的损耗(不播放任何声音的时候也在损耗)。

环境

  • codeg 0.32.2(最新 release)
  • macOS,MacBook Air M4(Mac16,13),16GB
  • 电池 Cycle Count 72 / 最大容量 100% / Condition Normal —— 已排除电池老化,纯粹是软件负荷

现象与实测

  1. codeg 启动后,coreaudiod 稳定在 6.1% ~ 6.7% CPU,与当前负载无关(连续 20 秒采样,数值几乎不动):
  573   6.7  /usr/sbin/coreaudiod
  573   6.6  /usr/sbin/coreaudiod
  573   6.3  /usr/sbin/coreaudiod
  573   6.1  /usr/sbin/coreaudiod
  1. pmset -g assertions 里有一条由 coreaudiod 持有的扬声器断言,Created for PID 指向 codeg 的 WebKit GPU 进程,持续时间正好等于 codeg 本次启动至今(实测 6 分 25 秒,与进程 etime 一致):
pid 573(coreaudiod): [...] PreventUserIdleSystemSleep named: "com.apple.audio.BuiltInSpeakerDevice.context.preventuseridlesleep"
	Created for PID: 8254.
	Resources: audio-out BuiltInSpeakerDevice

8254 是 /System/Library/.../com.apple.WebKit.GPU.xpc,与 codeg 主进程同时启动(属于 codeg 的 webview)。

  1. 采样该 WebContent 进程,线程列表里存在 RemoteAudioDestinationProxy render thread —— webview 侧确实有一条活跃的音频输出流在渲染。

  2. 关闭 codeg 后,上述断言立刻消失、coreaudiod 回落到 1% 以下。开启音效 + 点一下窗口 → 重启 codeg → 立刻复现。

复现步骤

  1. Settings → General 打开通知音效(settings:notification-sound:v1 中 enabled: true)
  2. 在 codeg 窗口里做任意一次点击/按键(触发手势解锁)
  3. 之后一声提示音都不播,coreaudiod 也会一直占着扬声器,并持有上述 anti-sleep 断言

(默认值 enabled: false 是安全的,所以只有主动开启音效的用户会踩到。)

根因

src/lib/notification-sound.ts:

  • 模块级单例:let context: AudioContext | null = null
  • getAudioContext() 创建后只挂 watchOutputState(context)(statechange 监听)
  • primeNotificationSoundOutput() → armUnlockOnGesture() 会在用户第一次 pointerdown / keydown / touchstart 时创建 context 并 resume() 到 running

整个文件没有任何 ctx.suspend() 或 ctx.close(),因此 context 一旦进入 running 就保持到窗口销毁。

需要说明:文件里关于"playEventSound 不能等待 resume()"的那段设计(suspended 时 currentTime 冻结,排队的声音会在用户下次点击时迟到触发)是正确的,这里要修的不是那部分,而只是缺少释放路径。

建议修复(已实现并通过测试,按需可提 PR)

在最后一个音符播完后宽限一段时间再 suspend(),把设备还回去:

  • retireNote() 中当 scheduledNotes 清空时,arm 一个 IDLE_SUSPEND_MS 定时器
  • scheduleTone() 进入时清掉它(有新的提示音要播,就别释放)
  • 定时器到点,且 ctx.state === "running" 且没有仍在播的音符 → ctx.suspend()
  • 不需要额外写重新解锁:现有的 watchOutputState() 在 statechange 时会调用 armUnlockOnGesture(),所以下次点击就会重新打开输出

测试:新增 5 个用例,全套 24/24 通过;并做了变异验证(撤掉源码改动后,其中 2 个关键用例立刻失败),确认测试确实覆盖这个 bug。eslint 与 tsc --noEmit 均干净。

取舍(需要你决定):宽限期内到达的提示音会取消释放;若超出宽限期且期间没有手势重新解锁,那一声会被丢掉。丢提示音本身是现有代码已定义并接受的路径(playEventSound 返回 false 且不消耗冷却,所以下一声照响)。我先取了 5 分钟 —— 用电脑时点击/打字会立刻重新打开输出,只有"纯盯着看超过 5 分钟"才会漏。

另一个可选方向是:下一次事件到达时先尝试一次无手势的 resume(),成功就直接补播,失败再回落到手势解锁。这可能让"走开等长任务完成"的核心场景不再漏声,但它和测试中"没播出去的提示音不消耗冷却"这一不变量会冲突,需要一起重新设计,所以我没有把它放进上面的最小改动里。

备注

另外观察到一个独立问题(还没定位完,先不混在这个 issue 里,定位到后另开):在「回合进行中但 agent 没有输出文字」的状态下,WebContent 主线程持续执行 rAF 回调并触发 MutationObserver,受控实测 30 秒消耗 5.39s CPU(约 18% 一个核心)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions