diff --git a/doc/observability-contract.md b/doc/observability-contract.md index b18d2cae8..ad430d533 100644 --- a/doc/observability-contract.md +++ b/doc/observability-contract.md @@ -13,11 +13,11 @@ 合并成一张图的理由是它们问的是同一个问题:**哪些量能红能绿、哪些只能看、只看的那些怎么标注**。走到这里,接手 [#8 support stream programme](https://github.com/wt159/SoundBridge/issues/8) 的 E1–E15 实现 effort 不必再为观测口径做决定。(**2026-11-08 时点说明**:本句写于 #94 起图时,当时把 E1–E15 当作尚未执行的未来 effort;实际该 effort 已由 #55–#69 于 2026-11-06 全部完成,见 §8.3 事实更正。保留原句作为当时动机的留痕。) -**界外**(详见地图 Out of scope):#8 的 E1–E15 实现(**写于起图时;该 effort 已完成,见 §8.3**)、网络流 / URL 数据源、播放器状态机与公开 API 签名演进、新增可注入错误接缝、补素材矩阵 / 合成长音频、改既有判据数值。 +**界外**(详见地图 Out of scope):#8 的 E1–E15 实现(**写于起图时;该 effort 已完成,见 §8.3**)、网络流 / URL 数据源、播放器状态机与公开 API 签名演进、新增可注入错误接缝(**2026-11-08 例外**:[#109](https://github.com/wt159/SoundBridge/issues/109) 单开 effort 重审后批准**一个**默认关闭的设备错误注入点,[#110](https://github.com/wt159/SoundBridge/issues/110) 落地 C1/C5 判据;该例外不推广到其它接缝、不新增判据数值,见 `doc/player-state-contract.md` §10.4)、补素材矩阵 / 合成长音频、改既有判据数值。 **一次性批量授权(2026-11-08)**:用户以「继续」推进到本图,此前已明确「需要我拍板的问题都选择你的推荐」;九张票按各自推荐答案连续收口,完整依据留在各票 resolution comment,地图只留 gist + 链接。与 2026-11-05 / 2026-11-07 的两次批量授权同构。 -**不可推翻前提**:四个仪表与三个独立指标现状只记录、不设阈值;`StreamingBudget` 观测 step 是 `continue-on-error` 纯观测、非门禁;端到端 drift 在 `StreamMetrics` 里没有现成仪表;公开契约保持三态、错误不进状态面、控制入口保持 `void`;C1 / C5 判据缺口因「不新造接缝」保持登记状态。 +**不可推翻前提**:四个仪表与三个独立指标现状只记录、不设阈值;`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 设备错误不跳轨缺端到端判据** 保持原状,本图**不收口**:界外已排除「新增可注入错误接缝」,只观测不注入覆盖不到带状态变化的错误路径。规格留此指针,避免后来者误以为漂移观测已经补上了这个洞。 +§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 1f1e9c86f..00a70fa90 100644 --- a/doc/player-state-contract.md +++ b/doc/player-state-contract.md @@ -149,6 +149,7 @@ - **判据不新造**:沿用 `max(1%,150ms)`、`r × good`、5pp、零容差差分与上述既有数值;坏输入 55 例 / 覆盖度 15 行是不可动基线。 - 线程契约用 `promise/future` 或轮询验证状态迁移(`sdk/test/unit/AGENTS.md:34`),不裸 sleep;重入 / 所有权 / ABI 类只写头文件注释 + 本规格,**不做最小可测替身**(回调内发命令会自锁,测不出有效结论)。 +- **2026-11-08 条款重审([#109](https://github.com/wt159/SoundBridge/issues/109))**:上面「判据不新造 / 不做最小可测替身」禁止的是**为验证而造第二套可测替身或另立判据数值**;C1 / C5 的缺口属于「契约已有、但无法注入错误」的另一类问题。[#109](https://github.com/wt159/SoundBridge/issues/109) 单开 effort 重审后批准**一个**默认关闭的注入点(`AudioDevice::setForceOpenFailureForTest()`,落点见 §10.4),不新增判据数值、不改既有判据、不新增 `add_test` 分组。这是**一次限定例外**,不构成可推广的新接缝惯例;将来若要再新增注入点须另开票重审。 - 落点分工:头文件注释 = 线程 / 重入 / 异常 / 所有权;本规格 = 全部契约表;ADR 0004 = 仅「三态」这一条。 - **CI 不动**:14 个 `sdk_*` 目标、TIMEOUT、快集 / 全量集划分、耗时 gate 全部沿用 #52 决策;StreamingBudget 仍是「只记录不设门槛」的先例。新增用例若写文件必须带 pid(`#76` 与 `sdk-windows.yml -j4` 的教训)。 @@ -195,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。**2026-11-08 O 链收口后复核**:O1–O6 均已有终态,本项是**唯一仍保留的判据缺口**,且属「已登记、已解释、已给未来条件」的已知代价,不是待裁决 fog。要补判据必须单开 effort 重审 §8「不新造接缝」的界外条款(须由人拍板)并提供可控的错误注入接缝;在此之前维持现状,不改写 C1 / C5 的实现与落点。 +- §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) @@ -210,5 +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 的 error→state 顺序与 C5 的「设备错误不跳轨」缺端到端判据 —— 现有接缝无法稳定注入「带状态变化的错误」,而新增可注入接缝违反 §8「不新造接缝」。这两条由生产链改动 + 代码审查 + 全量回归锁定;将来若出现可控的错误注入接缝,应补判据。 -- **fog**:契约漂移的长期观测口径本 effort 未裁决,交后续 effort;若只记录不设门槛,须写明「不作为失败门禁」。 +- **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 闸门,逐轮耗时 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 配置 / 任何既有判据数值。**反悔**:若将来要撤掉注入点,须另开新票并同时处理这两条判据的归属,不得只删判据留下「契约无门禁」;该反悔条款同时适用于 `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 1512c0b62..24ac3d89c 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,27 @@ int AudioDevice::selectDevice(uint64_t id) return m_impl->selectDevice(id); } +namespace { +// C1 / C5 端到端判据(票 #110):默认关闭的设备错误注入开关。 +// 写端是测试线程(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_release); +} + int AudioDevice::open() { + 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 6d416b021..be437808d 100644 --- a/sdk/audio/device/AudioDevice.h +++ b/sdk/audio/device/AudioDevice.h @@ -27,6 +27,11 @@ class AudioDevice : public NonCopyable { int getDeviceSpec(AudioSpec &spec); int selectDevice(uint64_t id); int open(); + // 仅供单元测试(票 #110):强制 open() 返回失败,用来构造「设备错误 → + // error→state」的端到端判据。默认关闭;读点只在控制线程调用 open() 时, + // 不进 Impl::open()、不碰 SDL。AudioDevice 是 SDK 内部类型,本钩子不进入 + // soundbridge::Player 的对外公开 API(票 #109 的一次限定例外)。 + 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;