From b40043af6b716082017503a92b9d3357791f5220 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 21:14:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20C1/C5=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E8=90=BD=E5=9C=B0=E9=BB=98=E8=AE=A4=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E7=9A=84=E8=AE=BE=E5=A4=87=E9=94=99=E8=AF=AF=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E7=82=B9=EF=BC=88#110=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AudioDevice 加 setForceOpenFailureForTest():文件级 atomic 默认 false,只在 open() 包装层读一次,命中直接 return -1、不进 Impl::open() / 不碰 SDL;MusicPlayer.cpp 播放逻辑零改动,播放链走的是生产里真实存在的设备失败分支(:326-330 → onMusicPlayListError:707) - 开关只在控制线程 _play()→open() 路径被读,不落解码线程 / 10 ms 消费主循环 / 判据主循环(O1 对照实验:额外写曾把 -j4 假红率从 0 抬到约 30%) - 新增一条用例同时覆盖 C1(error→state 顺序、发且仅发一次状态、Stopped、错误码 AudioDeviceOpenFailed)与 C5(默认 autoSkip 开启也不跳到第二首、不触发第二次播放尝试) - 双曲目列表 + 队列稳定窗口做屏障,不靠裸 sleep / 绝对时长窗口 / 特定 demuxer 行为;RAII guard 保证 REQUIRE 抛异常时也恢复进程级开关 - 变异验证:去掉 SkipTrack 检查 → 6 条断言红(含 C5 的「索引不变」「只报一次 error」);把 onError 挪到 state 之后 → errAt < stAt 精确红;还原后 12/12 断言绿 - 不碰 soundbridge::Player 公开头 / 七个 void 控制入口 / CI 配置 / CMakeLists / 任何既有判据数值;不改 14 个 sdk_* 目标与 TIMEOUT --- sdk/audio/device/AudioDevice.cpp | 17 ++++ sdk/audio/device/AudioDevice.h | 4 + sdk/test/unit/test_player_integration.cpp | 111 ++++++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/sdk/audio/device/AudioDevice.cpp b/sdk/audio/device/AudioDevice.cpp index 1512c0b62..b42992763 100644 --- a/sdk/audio/device/AudioDevice.cpp +++ b/sdk/audio/device/AudioDevice.cpp @@ -1,6 +1,7 @@ #include "AudioDevice.h" #include "LogWrapper.h" #include +#include #include #include @@ -299,8 +300,24 @@ int AudioDevice::selectDevice(uint64_t id) return m_impl->selectDevice(id); } +namespace { +// C1 / C5 端到端判据(票 #110):默认关闭的设备错误注入开关。 +// 只在 AudioDevice::open() 里读一次,而 open() 由控制线程的 _play() 调用; +// 绝不落在解码线程 / 10 ms 消费主循环 / 判据主循环,避免放大 ctest -j4 的 +// 调度抖动(O1 对照实验:额外写曾把既有判据假红率从 0 抬到约 30%)。 +std::atomic g_forceOpenFailure(false); +} // namespace + +void AudioDevice::setForceOpenFailureForTest(bool enabled) +{ + g_forceOpenFailure.store(enabled, std::memory_order_relaxed); +} + int AudioDevice::open() { + if (g_forceOpenFailure.load(std::memory_order_relaxed)) { + return -1; + } return m_impl->open(); } diff --git a/sdk/audio/device/AudioDevice.h b/sdk/audio/device/AudioDevice.h index 6d416b021..35cf3c495 100644 --- a/sdk/audio/device/AudioDevice.h +++ b/sdk/audio/device/AudioDevice.h @@ -27,6 +27,10 @@ class AudioDevice : public NonCopyable { int getDeviceSpec(AudioSpec &spec); int selectDevice(uint64_t id); int open(); + // 仅供单元测试(票 #110):强制 open() 返回失败,用来构造「设备错误 → + // error→state」的端到端判据。默认关闭;只在控制线程的 _play() → open() + // 路径上被读一次,不进 Impl::open()、不碰 SDL,也不属于对外公开 API。 + static void setForceOpenFailureForTest(bool enabled); int close(); bool isOpen() const; int start(); diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index cf22c44af..916c7d780 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -1,5 +1,6 @@ #include "../fixtures/RealMediaFixture.hpp" #include "../mocks/PlayerMocks.hpp" +#include #include #include #include @@ -46,6 +47,16 @@ bool waitFor(Predicate predicate, int timeoutMs, int stepMs = 20) return predicate(); } +// 票 #110:设备错误注入开关的 RAII guard,保证 REQUIRE 抛异常时也恢复默认状态, +// 不把进程级开关泄漏给同一测试二进制里的后续用例。 +class ForceAudioDeviceOpenFailure { +public: + ForceAudioDeviceOpenFailure() { AudioDevice::setForceOpenFailureForTest(true); } + ~ForceAudioDeviceOpenFailure() { AudioDevice::setForceOpenFailureForTest(false); } + ForceAudioDeviceOpenFailure(const ForceAudioDeviceOpenFailure &) = delete; + ForceAudioDeviceOpenFailure &operator=(const ForceAudioDeviceOpenFailure &) = delete; +}; + // 位置推进速率回落到「实时」才算进入稳态:起播后消费端会先把设备队列填到高水位 // (256KB),这段时间 position 按 2x 左右飞奔;队列满了才 1:1 跟墙钟。用连续两个 // 100ms 窗口都落在 1.6x 以内作为判据,比等某个绝对位置更能适应冷/热缓存下的解码速度差。 @@ -267,6 +278,106 @@ TEST_SUITE("MusicPlayer Error Handling") CHECK_MESSAGE(events.front() == "error", "错误必须是这个场景的第一个事件"); } + // #110 / C1 + C5 端到端判据:设备错误必须 error→state(Stopped) 且不跳轨。 + // 注入点只在 AudioDevice::open() 包装层,播放链走的是生产里真实的设备失败 + // 分支(MusicPlayer.cpp:326-330 → onMusicPlayListError:707),不改写播放逻辑。 + TEST_CASE("audio device error reports error before state and does not skip track") + { + RealMediaFixture fixture; + if (!fixture.exists("music.wav")) { + skipCase("media missing: music.wav"); + return; + } + // 两首曲目:C5 要能红能绿,必须存在「可以跳过去的下一首」。 + // 单曲列表里 skipToNextPlayable() 只会失败,跳轨与否几乎不可观测。 + TempMediaDir temp("deverror"); + const std::vector intact = readAllBytes(fixture.mediaPath("music.wav")); + if (intact.empty() || !writeAllBytes(temp.file("first.wav"), intact) + || !writeAllBytes(temp.file("second.wav"), intact)) { + skipCase("cannot synthesize two-track playlist"); + return; + } + + MockMusicPlayerListener listener; + std::string logDir = "./log"; + MusicPlayer player(&listener, logDir); + player.addMusicDir(temp.dir()); + if (!waitFor([&player]() { return player.getMusicCount() == 2; }, 5000) + || !waitFor([&listener]() { return listener.list_change_count.load() > 0; }, 5000)) { + skipCase("two-track playlist did not load"); + return; + } + player.setCurrentIndex(0); + if (!waitFor([&listener]() { return listener.last_current_index.load() == 0; }, 2000)) { + skipCase("cannot select the track"); + return; + } + + // 只观察 play() 之后新增的事件,避免把选曲 / 列表回调算进顺序判据。 + const size_t eventBaseline = listener.eventSequence().size(); + const int stateBaseline = listener.state_change_count.load(); + + ForceAudioDeviceOpenFailure forceFailure; + player.play(); + // 不写成 REQUIRE:设备错误被误判为可跳轨时,同步 error→state 序列可能 + // 根本不出现;用 CHECK 让后续 C1 / C5 断言也跑完,红色信息更具体。 + const bool sequenceArrived = waitFor([&listener, stateBaseline]() { + return listener.error_count.load() > 0 + && listener.state_change_count.load() > stateBaseline; + }, 3000); + CHECK_MESSAGE(sequenceArrived, + "设备错误应同步产出 error→state 序列,不得转入跳轨分支"); + + // 队列屏障:onMusicPlayListError 的跳轨分支会把 _play() 再排进 WorkQueue, + // 它的第二次 error / 索引变化是异步尾巴。等 error / state / index 三个计数 + // 连续 4 次采样都不变再取数,避免把「尾巴还没到」误判成「没有跳轨」。 + // 这是「稳定后取数」的屏障,不是绝对时长判据,也不落解码 / 消费线程。 + int quietSamples = 0; + int lastError = -1, lastState = -1, lastIndex = -1; + const bool queueQuiet = waitFor([&]() { + const int e = listener.error_count.load(); + const int s = listener.state_change_count.load(); + const int i = listener.index_change_count.load(); + if (e == lastError && s == lastState && i == lastIndex) { + ++quietSamples; + } else { + quietSamples = 0; + lastError = e; lastState = s; lastIndex = i; + } + return quietSamples >= 4; + }, 2000, 25); + CHECK_MESSAGE(queueQuiet, "队列应在 error→state 之后稳定,不得持续跳轨重试"); + + // eventSequence() 取锁返回后,回调线程对 last_state / last_error_code 的写 + // 已对测试线程可见;与既有用例的读法一致。 + const std::vector allEvents = listener.eventSequence(); + REQUIRE(allEvents.size() >= eventBaseline); + const std::vector events(allEvents.begin() + eventBaseline, + allEvents.end()); + const size_t errAt = MockMusicPlayerListener::firstEvent(events, "error"); + const size_t stAt = MockMusicPlayerListener::firstEvent(events, "state"); + + // C1:原因先于结果,且发且仅发一次状态事件(m_stateNotified 初值为 false, + // 首播即失败仍要通知一次,见 MusicPlayer.cpp 的 updatePlayState 注释)。 + CHECK_MESSAGE(errAt < events.size(), "设备错误必须上报 onError"); + CHECK_MESSAGE(stAt < events.size(), "设备错误必须发一次状态事件"); + CHECK_MESSAGE(errAt < stAt, "onError 必须早于 onStateChanged"); + CHECK_MESSAGE(MockMusicPlayerListener::countEvents(events, "state") == 1, + "设备错误应发且仅发一次状态事件"); + CHECK(listener.last_state == MusicPlayerState::StoppedState); + CHECK(listener.last_error_code == ErrorCode::AudioDeviceOpenFailed); + + // C5:设备错误是 StopPlayback,默认开启自动跳轨也不得跳到第二首。 + // play() 自己会上报一次当前索引("track" 事件),所以判据用「索引值」 + // 与「只有一次 error」,而不是「没有 index 回调」。 + CHECK(player.autoSkipOnError() == true); + CHECK_MESSAGE(listener.last_current_index.load() == 0, + "设备错误不该把当前曲目跳到第二首"); + CHECK_MESSAGE(MockMusicPlayerListener::countEvents(events, "error") == 1, + "设备错误不得触发第二次播放尝试(应且仅应上报一次)"); + player.stop(); + } + TEST_CASE("autoSkipOnError is enabled by default") { MockMusicPlayerListener listener; From e7e973966db71985546cf229a1583f0401002f51 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 21:38:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E5=9B=9E=E5=A1=AB=20C1/C5=20?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E8=A1=A5=E9=BD=90=E5=90=8E=E7=9A=84=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E7=BB=88=E6=80=81=E4=B8=8E=E8=AF=81=E6=8D=AE=EF=BC=88?= =?UTF-8?q?#109=20/=20#110=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - player-state-contract §8 补 #109 条款重审:区分「为验证另造可测替身 / 另立判据数值」(仍禁止)与「契约已有但无法注入错误」(本次一次限定例外),明确不推广为新接缝惯例 - §10.3 把「唯一仍保留的判据缺口 / 须人拍板」标注为收口前历史状态,终态改为缺口已补齐;§10.4 用完整落点替换原「已知判据缺口」条目:注入点形态、变异验证(6 条红 / errAt Date: Sun, 8 Nov 2026 21:57:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=B8=80=E6=AC=A1=E4=BF=AE=E5=AE=8C?= =?UTF-8?q?=20code-review=20=E4=B8=A4=E8=BD=B4=E5=AE=A1=E6=9F=A5=E6=8F=90?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=88#109=20/=20#110?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standards:AudioDevice.h 注释不再自相矛盾(明确 AudioDevice 是 SDK 内部类型、钩子不进入 soundbridge::Player 公开 API);注入开关改用 release/acquire,注释写明写端为测试线程 / 读端为控制线程、每次 open() 都读、误跳轨二次 open 同样命中 - Spec:三处规格把「终态 / 已补齐」降级为「集成分支落地、收口待 CI 双绿」;补待验证条款(PR 合并 → Linux sdk-coverage + Windows sdk-windows 双绿 → 关闭 #110 留 resolution),本地 Windows 全绿不算完成 - 补 O1 闸门逐轮耗时对照表(分支 10/10、master 9/10)与跨规格反悔通道指针;§10.4 起图时的 fog 行标注为历史留痕 - 不采纳 ScopeGuard 替换(判断项):手写 6 行 RAII guard 自包含、无新依赖,通用件语义需额外验证;其余 smell 均为判断项,无硬违规 - 重编译后新增用例 12/12 断言绿;不改既有判据数值 / CI 配置 / 14 个 sdk_* 目标与 TIMEOUT --- doc/observability-contract.md | 4 ++-- doc/player-state-contract.md | 11 ++++++----- sdk/audio/device/AudioDevice.cpp | 11 +++++++---- sdk/audio/device/AudioDevice.h | 5 +++-- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/doc/observability-contract.md b/doc/observability-contract.md index 2c24a4fd1..ad430d533 100644 --- a/doc/observability-contract.md +++ b/doc/observability-contract.md @@ -17,7 +17,7 @@ **一次性批量授权(2026-11-08)**:用户以「继续」推进到本图,此前已明确「需要我拍板的问题都选择你的推荐」;九张票按各自推荐答案连续收口,完整依据留在各票 resolution comment,地图只留 gist + 链接。与 2026-11-05 / 2026-11-07 的两次批量授权同构。 -**不可推翻前提**:四个仪表与三个独立指标现状只记录、不设阈值;`StreamingBudget` 观测 step 是 `continue-on-error` 纯观测、非门禁;端到端 drift 在 `StreamMetrics` 里没有现成仪表;公开契约保持三态、错误不进状态面、控制入口保持 `void`;C1 / C5 判据缺口因「不新造接缝」保持登记状态(**2026-11-08 终态**:该缺口已由 [#109](https://github.com/wt159/SoundBridge/issues/109) / [#110](https://github.com/wt159/SoundBridge/issues/110) 收口,判据已落地;「不设数值阈值」与 §3.2 第 5 步永久生效不变,见 `doc/player-state-contract.md` §10.4)。 +**不可推翻前提**:四个仪表与三个独立指标现状只记录、不设阈值;`StreamingBudget` 观测 step 是 `continue-on-error` 纯观测、非门禁;端到端 drift 在 `StreamMetrics` 里没有现成仪表;公开契约保持三态、错误不进状态面、控制入口保持 `void`;C1 / C5 判据缺口因「不新造接缝」保持登记状态(**起图时的历史前提**)。**2026-11-08 更新**:[#109](https://github.com/wt159/SoundBridge/issues/109) 重审该前提并批准一次限定例外、[#110](https://github.com/wt159/SoundBridge/issues/110) 在集成分支落地判据,收口须待 PR 合并与 CI 双绿(见 `doc/player-state-contract.md` §10.4 待验证项);「不设数值阈值」与 §3.2 第 5 步永久生效不变。撤掉注入点的反悔通道见该规格 §10.4。 ## 2. 术语与对象分类 @@ -204,7 +204,7 @@ **不开 `docs/adr/0005`**。三条判据逐条不成立:不改公开 API、不改变状态集合,没有难反悔的架构决策;「只记录 + 非门禁」是 #68 先例的直接沿用,无背景也不会惊讶;唯一的真实权衡「不为缺基线指标拍阈值」是地图前提,不是新架构决策。与 `docs/adr/0004-player-state-three-values.md` 是引用关系,不新增 ADR。 -§10.4 登记的 **C1 error→state 顺序 / C5 设备错误不跳轨缺端到端判据** 保持原状,本图**不收口**:界外已排除「新增可注入错误接缝」,只观测不注入覆盖不到带状态变化的错误路径。规格留此指针,避免后来者误以为漂移观测已经补上了这个洞。**2026-11-08 收口**:用户拍板单开 effort,[#109](https://github.com/wt159/SoundBridge/issues/109) 重审「不新造接缝」、[#110](https://github.com/wt159/SoundBridge/issues/110) 落地判据,本洞**已补齐**(落点与证据见 `doc/player-state-contract.md` §10.4)。本段「保持原状 / 本图不收口」是 #94 起图时的历史结论,保留作留痕;#94 的观测口径本身未变。 +§10.4 登记的 **C1 error→state 顺序 / C5 设备错误不跳轨缺端到端判据** 保持原状,本图**不收口**:界外已排除「新增可注入错误接缝」,只观测不注入覆盖不到带状态变化的错误路径。规格留此指针,避免后来者误以为漂移观测已经补上了这个洞。**2026-11-08 状态更新**:用户拍板单开 effort,[#109](https://github.com/wt159/SoundBridge/issues/109) 重审「不新造接缝」、[#110](https://github.com/wt159/SoundBridge/issues/110) 在集成分支落地判据;**收口须待 PR 合并与 CI 双绿**(见 `doc/player-state-contract.md` §10.4 待验证项),在此之前不得称「已补齐」。本段「保持原状 / 本图不收口」是 #94 起图时的历史结论,保留作留痕;#94 的观测口径本身未变。反悔通道(撤掉注入点须另开新票并同时处理两条判据的归属)见该规格 §10.4。 ## 8. 验收判据与回归基线 diff --git a/doc/player-state-contract.md b/doc/player-state-contract.md index a104627a2..00a70fa90 100644 --- a/doc/player-state-contract.md +++ b/doc/player-state-contract.md @@ -196,7 +196,7 @@ - fog「契约漂移的长期观测口径」→ **2026-11-08 已由地图「观测指标、drift 判定与契约漂移守护」([#94](https://github.com/wt159/SoundBridge/issues/94))收口**,见 `doc/observability-contract.md` §7:观测对象限定为公开契约面,长期观测 = 规格 / ADR 版本化记录 + 既有可红可绿判据,不新增 CI step / 日志摘要 / 第二个真相源;「不作为失败门禁」的标准措辞见其 §3.1,并明确「观测记录非门禁」与「契约本身仍由既有判据门禁覆盖」二者不互替。本图不新开 ADR。 - **已知代价**:三态无法区分「准备中」与「停止」,调用方只能靠 `trackCount()` / 回调组合判断;error→state 的顺序调整会让播放列表错误路径的 UI 提示相对状态晚一步,属可接受代价(避免 autoSkip 场景下先发一个不存在的状态)。 - 端到端 drift / 切换开销 / 观测指标判据仍是 #42 残留 fog 与 #68 交棒的另一 effort,与本图无关。**2026-11-08 更新**:该 effort 已由 [#94](https://github.com/wt159/SoundBridge/issues/94) 裁决完毕,见 `doc/observability-contract.md` §4–§6;数值阈值仍按定标流程留待后续 effort,本图不改。 -- §10.4 的 **C1 / C5 端到端判据缺口**曾长期保持登记状态:#94 的「只观测不注入」覆盖不到带状态变化的错误路径,界外已排除「新增可注入错误接缝」,见 `doc/observability-contract.md` §7.4;O 链收口时本项曾是「唯一仍保留的判据缺口」,属已登记 / 已解释 / 已给未来条件的已知代价,不是待裁决 fog。**2026-11-08 终态**:用户拍板单开 effort,[#109](https://github.com/wt159/SoundBridge/issues/109) 重审 §8「不新造接缝」并批准一个默认关闭的注入点,[#110](https://github.com/wt159/SoundBridge/issues/110) 落地判据,**缺口已补齐**;落点、变异验证与 `ctest -j4` 对照证据见 §10.4。此前「要补判据须由人拍板 / 在此之前维持现状」是收口前的历史表述,保留作决策留痕。 +- §10.4 的 **C1 / C5 端到端判据缺口**曾长期保持登记状态:#94 的「只观测不注入」覆盖不到带状态变化的错误路径,界外已排除「新增可注入错误接缝」,见 `doc/observability-contract.md` §7.4;O 链收口时本项曾是「唯一仍保留的判据缺口」,属已登记 / 已解释 / 已给未来条件的已知代价,不是待裁决 fog。**2026-11-08 进展**:用户拍板单开 effort,[#109](https://github.com/wt159/SoundBridge/issues/109) 重审 §8「不新造接缝」并批准一个默认关闭的注入点,[#110](https://github.com/wt159/SoundBridge/issues/110) 在集成分支落地判据;落点、变异验证与 `ctest -j4` 对照证据见 §10.4。**此时尚未收口**:PR 合并、Linux / Windows CI 双绿与票 #110 关闭并留 resolution 之后才能称「缺口已补齐」,见 §10.4 的待验证项。此前「要补判据须由人拍板 / 在此之前维持现状」是收口前的历史表述,保留作决策留痕。 ### 10.4 实现落地记录(2026-11-08) @@ -211,9 +211,10 @@ - **回归与审查**:本地 `ctest -j4` 14/14 绿(含坏输入四段与覆盖度);一次 `code-review` 提出的 4 条 Major(会话代际漏钳制、状态去重键错误、用例与目标路径错配、裸 sleep / 无声卡 flaky)与 4 条 Minor 已在一个修复提交里全部处理,细节见各票 resolution comment。既有容差数值、坏输入 55 例、14 个 `sdk_*` 目标与 TIMEOUT 全部未动。 - **务实取舍**:C1–C6 都改同一批文件,且本机 Qt/ffmpeg 依赖与 `build/` 缓存绑定主工作区,多 worktree 独立编译不可行;故 implementer 按拓扑序在同一 integration branch 上逐票提交,task graph、原生阻塞边与一次 `code-review` 均保留。 -- **C1 / C5 端到端判据已补齐(2026-11-08,[#109](https://github.com/wt159/SoundBridge/issues/109) / [#110](https://github.com/wt159/SoundBridge/issues/110))**:原缺口「现有接缝无法稳定注入带状态变化的错误」由 `AudioDevice::setForceOpenFailureForTest()` 解决 —— 只在 `AudioDevice::open()` 包装层短路返回失败,播放链走生产里**真实存在**的设备失败分支(`MusicPlayer.cpp:326-330` → `onMusicPlayListError:707`),`MusicPlayer.cpp` 播放逻辑零改动。新增用例 `test_player_integration.cpp` 的 `audio device error reports error before state and does not skip track` 用一条 `AudioDeviceOpenFailed` 序列同时验证 C1(`onError` 早于 `onStateChanged(Stopped)`、发且仅发一次、错误码正确)与 C5(默认 autoSkip 开启也不跳到第二首、不触发第二次播放尝试)。 +- **C1 / C5 端到端判据已在集成分支落地(2026-11-08,[#109](https://github.com/wt159/SoundBridge/issues/109) / [#110](https://github.com/wt159/SoundBridge/issues/110);收口待 CI)**:原缺口「现有接缝无法稳定注入带状态变化的错误」由 `AudioDevice::setForceOpenFailureForTest()` 解决 —— 只在 `AudioDevice::open()` 包装层短路返回失败,播放链走生产里**真实存在**的设备失败分支(`MusicPlayer.cpp:326-330` → `onMusicPlayListError:707`),`MusicPlayer.cpp` 播放逻辑零改动。新增用例 `test_player_integration.cpp` 的 `audio device error reports error before state and does not skip track` 用一条 `AudioDeviceOpenFailed` 序列同时验证 C1(`onError` 早于 `onStateChanged(Stopped)`、发且仅发一次、错误码正确)与 C5(默认 autoSkip 开启也不跳到第二首、不触发第二次播放尝试)。 - **可红可绿证据(变异验证)**:去掉 `ErrorAction::SkipTrack` 检查 → 6 条断言红(含 C5 的「索引不变」「只报一次 error」);把 `onError` 挪到状态事件之后 → `errAt < stAt` 精确红;还原后 12/12 断言绿。 - - **稳定性证据(O1 闸门)**:`ctest -j4` 集成分支 **10 / 10 轮全绿 14/14**(56.6–59.2 s);master 基线 **9 / 10 轮全绿**(第 9 轮 `sdk_interaction_tests` 一次既有低概率 flake,与本用例无关)。分支假红率不高于基线,注入开关只在控制线程读一次,未重现 O1 那种约 30% 假红放大。 + - **稳定性证据(O1 闸门,逐轮耗时 s)**:分支 `59.05, 59.17, 58.87, 56.65, 57.26, 57.60, 58.00, 57.60, 57.43, 58.04` → **10 / 10 轮全绿 14/14**;master `58.81, 59.91, 59.69, 58.61, 57.77, 58.23, 57.24, 59.42, 59.61, 57.94` → **9 / 10 轮全绿**(第 9 轮 `sdk_interaction_tests` 一次既有低概率 flake,与本用例无关)。分支假红率不高于基线,注入开关只在控制线程读一次,未重现 O1 那种约 30% 假红放大。 - 用例归属 **`sdk_unit_tests`**(`-tse` 排除集不含 `MusicPlayer Error Handling` suite),**不在** `sdk_interaction_tests`;票 #110 正文的目标名有误,已在其 comment 更正。 - - 不新增 `add_test` 分组、不改 14 个目标 / TIMEOUT / CI 配置 / 任何既有判据数值。**反悔**:若将来要撤掉注入点,须另开新票并同时处理这两条判据的归属,不得只删判据留下「契约无门禁」。 -- **fog**:契约漂移的长期观测口径本 effort 未裁决,交后续 effort;若只记录不设门槛,须写明「不作为失败门禁」。 + - 不新增 `add_test` 分组、不改 14 个目标 / TIMEOUT / CI 配置 / 任何既有判据数值。**反悔**:若将来要撤掉注入点,须另开新票并同时处理这两条判据的归属,不得只删判据留下「契约无门禁」;该反悔条款同时适用于 `doc/observability-contract.md` §1 / §7.4 的界外例外。 + - **待验证(收口前不得称完成)**:本地 Windows 全绿按 AGENTS.md **不算完成**;须等 PR 合并后 Linux `sdk-coverage` 与 Windows `sdk-windows` 双绿,并关闭 [#110](https://github.com/wt159/SoundBridge/issues/110) 留完整 resolution。在此之前本节的「落地」只描述集成分支状态。 +- **fog**(起图时的历史记录):契约漂移的长期观测口径本 effort 未裁决,交后续 effort;若只记录不设门槛,须写明「不作为失败门禁」。**2026-11-08 状态**:该 fog 已由地图 [#94](https://github.com/wt159/SoundBridge/issues/94) 裁决完毕,见 `doc/observability-contract.md` §7;此行保留作当时留痕。 diff --git a/sdk/audio/device/AudioDevice.cpp b/sdk/audio/device/AudioDevice.cpp index b42992763..24ac3d89c 100644 --- a/sdk/audio/device/AudioDevice.cpp +++ b/sdk/audio/device/AudioDevice.cpp @@ -302,20 +302,23 @@ int AudioDevice::selectDevice(uint64_t id) namespace { // C1 / C5 端到端判据(票 #110):默认关闭的设备错误注入开关。 -// 只在 AudioDevice::open() 里读一次,而 open() 由控制线程的 _play() 调用; -// 绝不落在解码线程 / 10 ms 消费主循环 / 判据主循环,避免放大 ctest -j4 的 +// 写端是测试线程(RAII 设置 / 恢复),读端是控制线程调用 open() 的路径; +// 用 release/acquire 保证设置对 _play() 可见,不依赖 relaxed 的偶然时序。 +// 读点绝不落在解码线程 / 10 ms 消费主循环 / 判据主循环,避免放大 ctest -j4 的 // 调度抖动(O1 对照实验:额外写曾把既有判据假红率从 0 抬到约 30%)。 +// 每次 open() 都读一次;误跳轨引发二次 open 时同样命中,这正是 C5 判据 +// 要检测的「第二次播放尝试」。 std::atomic g_forceOpenFailure(false); } // namespace void AudioDevice::setForceOpenFailureForTest(bool enabled) { - g_forceOpenFailure.store(enabled, std::memory_order_relaxed); + g_forceOpenFailure.store(enabled, std::memory_order_release); } int AudioDevice::open() { - if (g_forceOpenFailure.load(std::memory_order_relaxed)) { + if (g_forceOpenFailure.load(std::memory_order_acquire)) { return -1; } return m_impl->open(); diff --git a/sdk/audio/device/AudioDevice.h b/sdk/audio/device/AudioDevice.h index 35cf3c495..be437808d 100644 --- a/sdk/audio/device/AudioDevice.h +++ b/sdk/audio/device/AudioDevice.h @@ -28,8 +28,9 @@ class AudioDevice : public NonCopyable { int selectDevice(uint64_t id); int open(); // 仅供单元测试(票 #110):强制 open() 返回失败,用来构造「设备错误 → - // error→state」的端到端判据。默认关闭;只在控制线程的 _play() → open() - // 路径上被读一次,不进 Impl::open()、不碰 SDL,也不属于对外公开 API。 + // error→state」的端到端判据。默认关闭;读点只在控制线程调用 open() 时, + // 不进 Impl::open()、不碰 SDL。AudioDevice 是 SDK 内部类型,本钩子不进入 + // soundbridge::Player 的对外公开 API(票 #109 的一次限定例外)。 static void setForceOpenFailureForTest(bool enabled); int close(); bool isOpen() const;