From 3f81d8be5a70550ff930304694e7e8c0a0475295 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 12:04:46 +0800 Subject: [PATCH 01/12] =?UTF-8?q?build:=20=E6=96=B0=E5=A2=9E=20Windows=20C?= =?UTF-8?q?I=EF=BC=8C=E5=B9=B6=E8=AE=A9=20push=20=E5=8F=AA=E5=AF=B9=20mast?= =?UTF-8?q?er=20=E8=A7=A6=E5=8F=91=E4=BB=A5=E6=B6=88=E9=99=A4=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .github/workflows/sdk-windows.yml:windows-latest + 仓库已有的 scripts/setup_windows_env.ps1(CMake 3.31.6 + Qt 5.14.2 + MinGW 7.3.0), 编译后跑 ctest -R sdk_ 11 组,并像 Linux 侧一样用 -ts=MediaCoverage -s 打印覆盖度矩阵的 declared / measured,供两端逐项核对。 - 3rdparty 在 Windows 上只能源码构建(Boost / FFmpeg / SDL2 / FLAC / Ogg / Vorbis / zlib / g3log / g3sinks),首次 40~90 分钟,因此把 dist 目录整体 缓存,键只取第三方构建输入;缓存命中走 -SkipThirdParty,单次 10~15 分钟。 冷缓存时构建完立即删掉 3rdparty/build,避免打满 runner 磁盘。 - 全程用脚本装好的 CMake 3.31.6 绝对路径:runner 自带 CMake 若是 4.x, 会对 cmake_minimum_required(3.5) 直接报错。 - sdk-coverage.yml 的 push 触发收窄到 master,加 workflow_dispatch。 原先分支提交与 PR 各跑一遍同一份 commit,白等两轮 14 分钟。 --- .github/workflows/sdk-coverage.yml | 6 ++ .github/workflows/sdk-windows.yml | 129 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 .github/workflows/sdk-windows.yml diff --git a/.github/workflows/sdk-coverage.yml b/.github/workflows/sdk-coverage.yml index d68335071..4ce7361e5 100644 --- a/.github/workflows/sdk-coverage.yml +++ b/.github/workflows/sdk-coverage.yml @@ -1,7 +1,12 @@ name: sdk-coverage on: + # push 只对 master 生效:分支上的提交已经由 pull_request 触发一次, + # 两边都开会把同一份 commit 跑两遍(14 分钟 × 2,等待与配额都浪费)。 + # 需要单独验证某个分支时用 workflow_dispatch。 push: + branches: + - master paths: - 'CMakeLists.txt' - 'cmake/**' @@ -17,6 +22,7 @@ on: - 'scripts/**' - 'sdk/**' - '.github/workflows/sdk-coverage.yml' + workflow_dispatch: jobs: coverage: diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml new file mode 100644 index 000000000..4c47a3b4f --- /dev/null +++ b/.github/workflows/sdk-windows.yml @@ -0,0 +1,129 @@ +name: sdk-windows + +# Windows 侧回归。项目主力是 Windows + MinGW,但此前只有 Linux 覆盖: +# 2026-11-06 那个 seek 差异是 Linux 先撞出来的,反方向同样成立。 +# +# 成本说明:3rdparty(Boost / FFmpeg / SDL2 / FLAC / Ogg / Vorbis / zlib / g3log / g3sinks) +# 在 Windows 上只能源码构建,首次约 40~90 分钟。因此把 dist 目录整体缓存, +# 缓存命中时走 setup_windows_env.ps1 的 -SkipThirdParty,单次约 10~15 分钟。 +# 缓存键只包含第三方构建输入,业务代码改动不会让缓存失效。 +on: + push: + branches: + - master + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'app/**' + - 'scripts/**' + - 'sdk/**' + - '.github/workflows/sdk-windows.yml' + pull_request: + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'app/**' + - 'scripts/**' + - 'sdk/**' + - '.github/workflows/sdk-windows.yml' + workflow_dispatch: + +concurrency: + group: sdk-windows-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-latest + # 冷缓存(构建全部第三方库)留足余量;热缓存约 10~15 分钟。 + timeout-minutes: 150 + env: + # 无头 runner 没有音频设备,走 SDL 的 dummy 驱动(与 Linux 侧一致)。 + SDL_AUDIODRIVER: dummy + QT_ROOT: C:\Qt + TOOLS_ROOT: C:\tools + # runner 自带 CMake 可能是 4.x,对 cmake_minimum_required(3.5) 直接报错。 + # 全程使用 setup 脚本装好的 3.31.6,不依赖 PATH 顺序。 + CMAKE_BIN: C:\tools\cmake-3.31.6-windows-x86_64\bin + + steps: + - uses: actions/checkout@v7 + + # 第三方库是纯源码构建、与业务代码无关,按构建输入做键。 + # 业务代码改动不会命中不同的 key,所以日常 PR 都走热缓存。 + - name: Restore 3rdparty dist cache + id: dist + uses: actions/cache/restore@v4 + with: + path: sdk/3rdparty/dist/windows_x86_64_gcc_debug + key: 3rdparty-win-${{ hashFiles('sdk/3rdparty/CMakeLists.txt', 'sdk/3rdparty/build_option.cmake', 'cmake/toolchain/toolchain.windows_x86_64_mingw.cmake', 'sdk/3rdparty/ffmpeg-4.4.4/build.sh', 'sdk/3rdparty/boost_1_81_0/windows_build.bat', 'sdk/3rdparty/boost_1_81_0/bootstrap.bat') }} + restore-keys: | + 3rdparty-win- + + # 脚本本身是仓库里已验证的 Windows 一键环境(CMake 3.31.6 + Qt 5.14.2 + MinGW 7.3.0 + # + 可选的三方库构建)。这里不另写一套安装逻辑,避免 CI 与本地环境漂移。 + # 缓存命中时跳过三方库构建;根工程配置脚本总会做。 + - name: Setup toolchain (Qt / MinGW / CMake) and 3rdparty + shell: pwsh + run: | + $extra = @() + if ("${{ steps.dist.outputs.cache-hit }}" -eq "true") { + Write-Host "3rdparty dist cache hit: skip building dependencies" + $extra += "-SkipThirdParty" + } else { + Write-Host "3rdparty dist cache miss: building dependencies from source" + } + ./scripts/setup_windows_env.ps1 -QtRoot $env:QT_ROOT -ToolsRoot $env:TOOLS_ROOT -Jobs 4 -QtMirror https://download.qt.io -PyPIIndex https://pypi.org/simple @extra + + # 每个 step 都是新进程,脚本内改的 $env:Path 不会传给后续步骤,必须显式导出。 + # 注意顺序:这些 PATH 必须在配置根工程之前就位——sdk/test/CMakeLists.txt 会把 + # $ENV{PATH} 烘焙进 add_test 的 PATH,改晚了测试会找不到 DLL(0xc0000135)。 + # 脚本在最后一节已经用这份 PATH 配置过根工程,这里导出是为了后续 build / ctest 步骤。 + - name: Export PATH for later steps + shell: pwsh + run: | + @( + "$env:QT_ROOT\Tools\mingw730_64\bin", + "$env:QT_ROOT\5.14.2\mingw73_64\bin", + "$env:TOOLS_ROOT\cmake-3.31.6-windows-x86_64\bin" + ) | ForEach-Object { $_ | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 } + "QT_ROOT_PATH=$env:QT_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + # Boost 的中间产物体量很大,构建完就删,避免把 runner 磁盘吃满。 + - name: Drop 3rdparty build tree + if: steps.dist.outputs.cache-hit != 'true' + shell: pwsh + run: Remove-Item -LiteralPath sdk/3rdparty/build -Recurse -Force -ErrorAction SilentlyContinue + + - name: Build + shell: pwsh + run: | + & "$env:CMAKE_BIN\cmake.exe" --build build --parallel + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Run SDK tests + shell: pwsh + run: | + & "$env:CMAKE_BIN\ctest.exe" --test-dir build -R sdk_ --output-on-failure + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # 覆盖度矩阵的 declared / measured 只在断言失败时才由 doctest 打印,通过时数值不可见。 + # 用 -s 打开成功断言输出,把 Windows 侧实测毫秒数留在日志里,供与 Linux 侧逐项核对 + # (两侧必须落在同一容差内,见 doc/format-support.md)。与 Linux workflow 的同名步骤对齐。 + # 这里直接起 exe,不经过 add_test 烘焙的 PATH,所以要自己补 3rdparty 的 DLL 目录。 + - name: Report coverage matrix numbers + shell: bash + run: | + set -o pipefail + export PATH="$PWD/sdk/3rdparty/dist/windows_x86_64_gcc_debug/bin:$PATH" + ./build/sdk/test/UnitTests.exe -ts=MediaCoverage -s \ + | grep -E "declared |coverage matrix covered" + + # 冷缓存才需要回写;actions/cache 的 post 步骤只在 job 成功时保存, + # 这里显式保存,避免「构建成功但后续测试红」时整轮 90 分钟白跑。 + - name: Save 3rdparty dist cache + if: steps.dist.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: sdk/3rdparty/dist/windows_x86_64_gcc_debug + key: 3rdparty-win-${{ hashFiles('sdk/3rdparty/CMakeLists.txt', 'sdk/3rdparty/build_option.cmake', 'cmake/toolchain/toolchain.windows_x86_64_mingw.cmake', 'sdk/3rdparty/ffmpeg-4.4.4/build.sh', 'sdk/3rdparty/boost_1_81_0/windows_build.bat', 'sdk/3rdparty/boost_1_81_0/bootstrap.bat') }} From 55e0a5fb3eacdb372c49be9b0ebde41bf0e51286 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 12:32:27 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20setup=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=20-D=20=E5=8F=82=E6=95=B0=E6=9C=AA=E5=B1=95?= =?UTF-8?q?=E5=BC=80=EF=BC=8C=E5=AF=BC=E8=87=B4=20dist=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=90=8D=E5=B8=A6=20$buildtype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell 对未加引号的 `-Name=Value` 令牌不做变量展开,所以 `-DCMAKE_BUILD_TYPE=$BuildType` 原样传给 cmake,CMAKE_BUILD_TYPE 的字面值就是 "$BuildType"。toolchain 再把它小写化成 $buildtype,dist 目录于是变成 windows_x86_64_gcc_$buildtype。 - `$` 在 mingw32-make 里是变量前缀,include 路径里的 `$buildtype` 被展开成空, g3sinks 编译时找不到 boost/filesystem.hpp;同时根工程会去找一个不存在的 dist 目录。Windows CI 首轮(run 42336893127)就是卡在这里。 - 两处 cmake 调用都改成整体加引号。toolchain 那一行原本就是引号形式,所以它一直是对的, 这也是只有 build type 出问题的原因。 - AGENTS.md 的 NOTES 补上两个 CI 工作流的触发约定与 Windows 缓存说明。 本机用 -SkipQt -SkipThirdParty 复跑:`-- CMAKE_BUILD_TYPE: Debug`、 `3RDPARTY_DIST_DIR: .../dist/windows_x86_64_gcc_debug`,脚本正常收尾; build/CMakeCache.txt 里的 $BuildType 也已纠正为 Debug。 --- AGENTS.md | 6 ++++++ scripts/setup_windows_env.ps1 | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 312cbc2eb..52b2d0c59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,12 @@ cmake --build build --target package_portable ## NOTES - `doc/` 与 `README.md` 提供背景,但真实构建/测试入口以当前 `CMakeLists.txt` 为准。 +- **CI 有两个工作流**:`sdk-coverage`(Linux x86_64,含覆盖率门禁)与 `sdk-windows`(windows-latest + MinGW)。 + 两者的 `push` 都只对 `master` 生效,分支验证走 `pull_request`;需要单独验证某个分支用 + `gh workflow run --ref `。Windows 侧的 3rdparty 走 `sdk/3rdparty/dist` 缓存, + 缓存命中约 10~15 分钟,冷缓存 40~90 分钟;改动 `sdk/3rdparty` 的构建输入(CMakeLists / build_option / + toolchain / ffmpeg 与 boost 构建脚本)会让缓存失效。Windows 任务用脚本装好的 CMake 3.31.6 绝对路径, + 因为 runner 自带的 CMake 4.x 对 `cmake_minimum_required(3.5)` 直接报错。 - `sdk/cosmos/` 是 header-only 工具区;规则不够特殊,暂不单独挂子 AGENTS。 - `music/` 里包含真实媒体样本;涉及 extractor / decode 的改动,优先补 `sdk_media_smoke` 或 `sdk_extractor_spec_tests`。 - 用户口头说“提交一版”时,默认含义是:`git add` + `git commit` + `git push`;除非用户显式缩小范围,否则按这三个步骤执行。 diff --git a/scripts/setup_windows_env.ps1 b/scripts/setup_windows_env.ps1 index 1f48eaf66..8d375148f 100644 --- a/scripts/setup_windows_env.ps1 +++ b/scripts/setup_windows_env.ps1 @@ -159,9 +159,13 @@ if (-not $SkipThirdParty) { Push-Location $tpBuild try { # -DVERSION 用于跳过 g3log 中依赖 git 的版本号推导(该处嵌套引号在 Windows 上不可靠) + # -DVAR=值 必须整体加引号:PowerShell 对未加引号的 -Name=Value 令牌不做变量展开, + # 会把 -DCMAKE_BUILD_TYPE=$BuildType 原样传给 cmake,导致 CMAKE_BUILD_TYPE 字面量是 + # "$BuildType"、dist 目录名变成 windows_x86_64_gcc_$buildtype,而 '$' 在 mingw32-make + # 里是变量前缀,最终 include 路径被吃成空目录(2026-11-07 Windows CI 变红的根因)。 cmake .. -G "MinGW Makefiles" ` - -DCMAKE_BUILD_TYPE=$BuildType ` - -DCMAKE_TOOLCHAIN_FILE="$RepoRoot\cmake\toolchain\toolchain.windows_x86_64_mingw.cmake" ` + "-DCMAKE_BUILD_TYPE=$BuildType" ` + "-DCMAKE_TOOLCHAIN_FILE=$RepoRoot\cmake\toolchain\toolchain.windows_x86_64_mingw.cmake" ` -DVERSION=2.1.0 -UBUILD_OPTION_G3LOG -UBoost_USE_STATIC_LIBS if ($LASTEXITCODE -ne 0) { throw "第三方库配置失败" } @@ -192,9 +196,10 @@ $buildDir = Join-Path $RepoRoot "build" New-Item -ItemType Directory -Force -Path $buildDir | Out-Null Push-Location $buildDir try { + # 同样必须整体加引号,原因见上面第三方库那处的注释。 cmake .. -G "MinGW Makefiles" ` - -DCMAKE_BUILD_TYPE=$BuildType ` - -DCMAKE_TOOLCHAIN_FILE="$RepoRoot\cmake\toolchain\toolchain.windows_x86_64_mingw.cmake" + "-DCMAKE_BUILD_TYPE=$BuildType" ` + "-DCMAKE_TOOLCHAIN_FILE=$RepoRoot\cmake\toolchain\toolchain.windows_x86_64_mingw.cmake" if ($LASTEXITCODE -ne 0) { throw "根工程配置失败" } } finally { Pop-Location From f5a82122d8a06bd6cf156171587235a6a9fcbf9d Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 13:06:51 +0800 Subject: [PATCH 03/12] =?UTF-8?q?ci:=20Windows=20=E4=BE=A7=E5=8A=A0?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=96=87=E4=BB=B6=E5=90=8D=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8A=8A=20dist=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=9E=84=E5=BB=BA=E6=88=90=E5=8A=9F=E5=8D=B3?= =?UTF-8?q?=E5=9B=9E=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sdk-windows.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml index 4c47a3b4f..54b334a36 100644 --- a/.github/workflows/sdk-windows.yml +++ b/.github/workflows/sdk-windows.yml @@ -64,6 +64,7 @@ jobs: # + 可选的三方库构建)。这里不另写一套安装逻辑,避免 CI 与本地环境漂移。 # 缓存命中时跳过三方库构建;根工程配置脚本总会做。 - name: Setup toolchain (Qt / MinGW / CMake) and 3rdparty + id: setup shell: pwsh run: | $extra = @() @@ -101,6 +102,23 @@ jobs: & "$env:CMAKE_BIN\cmake.exe" --build build --parallel if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # 诊断用:素材里有一批中文文件名,Windows runner 上这批文件打不开而 ASCII 名的正常。 + # 先确认是「检出时文件名被改写」还是「打开时的编码转换失败」——列目录 + 看 git 是否认为有改动。 + - name: Diagnose media filenames (non-gating) + if: always() + shell: bash + continue-on-error: true + run: | + echo "=== git status(若检出改写了文件名,这里会显示删除)===" + git status --porcelain | head -30 + echo "=== git ls-files(仓库里的名字)===" + git ls-files music | head -25 + echo "=== 磁盘上的名字(python repr,看真实码点)===" + python -c "import os; [print(repr(n)) for n in sorted(os.listdir('music'))[:25]]" + echo "=== 纯 ASCII 与中文各挑一个做 open 测试 ===" + python -c "import io;print(io.open('music/music.wav','rb').read(4))" + python -c "import io,glob;f=[p for p in glob.glob('music/*.ogg')];print(f);print(io.open(f[0],'rb').read(4) if f else 'no ogg')" + - name: Run SDK tests shell: pwsh run: | @@ -121,8 +139,9 @@ jobs: # 冷缓存才需要回写;actions/cache 的 post 步骤只在 job 成功时保存, # 这里显式保存,避免「构建成功但后续测试红」时整轮 90 分钟白跑。 + # 条件挂在 setup 的 outcome 上:只有第三方库真的构建成功才值得缓存。 - name: Save 3rdparty dist cache - if: steps.dist.outputs.cache-hit != 'true' + if: steps.setup.outcome == 'success' && steps.dist.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: sdk/3rdparty/dist/windows_x86_64_gcc_debug From d93dd123688bc35e2c94348b8356deaccf032034 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 13:21:31 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20Windows=20=E4=B8=8A=E6=8C=89?= =?UTF-8?q?=E5=AE=BD=E5=AD=97=E7=AC=A6=E8=B7=AF=E5=BE=84=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=9D=9E=20ANSI?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=E5=90=8D=E6=89=93=E4=B8=8D=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:FileSource 用 std::ifstream 打开文件,路径经 toNativeString 从 UTF-8 转成 ANSI(CP_ACP)。这一步是有损的,CP_ACP 表示不了的字符会被替换成 '?': 中文系统(936)能表示中文所以本地一直好的,英文系统(1252)表示不了,于是 "Ringtones-耳聆网.ogg" 变成 "Ringtones-?????.ogg",文件打不开。 2026-11-07 新增的 Windows CI(en-US runner)首轮就是这么红的:21 个素材里 所有中文名的全部 REQUIRE(fixture.exists(...)) 失败,ASCII 名的都通过。 - 修法:Windows 分支改用 _wfopen + FILE*,路径用 toWideString 转宽字符, 与系统代码页无关;POSIX 分支仍是 std::ifstream,语义不变。 readAt / getSize / initCheck / 析构按平台各自实现,对外契约没动。 - Utf8Path.h 新增 toWideString;toNativeString 保留但加了醒目注释说明它是有损的、 不应再用于打开文件。 - 新增回归用例 `Non-ANSI filename opens through the wide path`:文件名用 emoji (U+1F600),它在 CP936 和 CP1252 里都没有映射,所以旧实现无论在哪国语言环境下都会 失败,不需要靠 runner 的语种才能复现。 - FileSource.h 仍无条件包含 :有测试文件依赖它顺带带来的 。 测试文件另需 #undef ERROR,因为 Utf8Path.h 在 Windows 上会带上 。 本机验证(ACP=936): - 探针用真实 FileSource 打开 emoji 文件名 —— 修复后 initCheck=0 / size=4 / readAt=4; 临时换回旧的 toNativeString + fopen 写法则 initCheck=-19 / size=-1 / readAt=-19。 - 新增用例在旧实现下 7 条断言挂 5 条,恢复后全绿。 - UnitTests 507 例 / 50167 断言 / 0 failed / skipped=0;ctest -R sdk_ 11/11(211.37s)。 --- sdk/extractor/FileSource.cpp | 66 ++++++++++++++++++++++++++++++-- sdk/extractor/FileSource.h | 11 ++++++ sdk/test/unit/test_extractor.cpp | 51 ++++++++++++++++++++++++ sdk/utils/Utf8Path.h | 30 ++++++++++++--- 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/sdk/extractor/FileSource.cpp b/sdk/extractor/FileSource.cpp index ec122a7f4..874e22951 100644 --- a/sdk/extractor/FileSource.cpp +++ b/sdk/extractor/FileSource.cpp @@ -9,7 +9,11 @@ using namespace sdk_utils; FileSource::FileSource(const char *filename) - : m_file(sdk_utils::toNativeString(filename), std::ios::binary) +#ifdef _WIN32 + : m_fp(nullptr) +#else + : m_file() +#endif , m_offset(0) , m_length(-1) , m_name("") @@ -21,6 +25,28 @@ FileSource::FileSource(const char *filename) } LOG_DEBUG(LOG_TAG, "%s", filename ? filename : "(null)"); + +#ifdef _WIN32 + // 宽字符路径:与系统代码页无关,非 ANSI 的文件名(中文、日文、emoji)都能打开。 + if (filename && filename[0] != '\0') { + const std::wstring wide = sdk_utils::toWideString(filename); + if (!wide.empty()) { + m_fp = _wfopen(wide.c_str(), L"rb"); + } + } + if (m_fp != nullptr) { + if (_fseeki64(m_fp, 0, SEEK_END) == 0) { + m_length = static_cast(_ftelli64(m_fp)); + _fseeki64(m_fp, 0, SEEK_SET); + } + LOGI("file size: %lld", (long long)m_length); + } else { + LOG_ERROR(LOG_TAG, "failed to open file '%s'", filename ? filename : "(null)"); + } +#else + if (filename && filename[0] != '\0') { + m_file.open(filename, std::ios::in | std::ios::binary); + } if (m_file.is_open()) { m_file.seekg(0, std::ios::end); m_length = m_file.tellg(); @@ -29,23 +55,40 @@ FileSource::FileSource(const char *filename) } else { LOG_ERROR(LOG_TAG, "failed to open file '%s'", filename ? filename : "(null)"); } +#endif } FileSource::~FileSource() { +#ifdef _WIN32 + if (m_fp != nullptr) { + std::fclose(m_fp); + m_fp = nullptr; + } +#else if (m_file.is_open()) { m_file.close(); } +#endif +} + +bool FileSource::isOpen() const +{ +#ifdef _WIN32 + return m_fp != nullptr; +#else + return m_file.is_open(); +#endif } status_t FileSource::initCheck() const { - return m_file.is_open() ? OK : NO_INIT; + return isOpen() ? OK : NO_INIT; } ssize_t FileSource::readAt(off64_t offset, void *data, size_t size) { - if (!m_file.is_open()) { + if (!isOpen()) { return NO_INIT; } std::lock_guard lock(m_lock); @@ -60,6 +103,20 @@ ssize_t FileSource::readAt(off64_t offset, void *data, size_t size) } } +#ifdef _WIN32 + if (_fseeki64(m_fp, offset + m_offset, SEEK_SET) != 0) { + LOG_ERROR(LOG_TAG, "seek to %lld failed", (long long)(offset + m_offset)); + return UNKNOWN_ERROR; + } + + const size_t got = std::fread(data, 1, size, m_fp); + if (got != size && std::ferror(m_fp) != 0) { + LOG_ERROR(LOG_TAG, "read file failed"); + return FAILED_TRANSACTION; + } + + return static_cast(got); +#else m_file.seekg(offset + m_offset); if (m_file.fail()) { LOG_ERROR(LOG_TAG, "seek to %lld failed", (long long)(offset + m_offset)); @@ -74,13 +131,14 @@ ssize_t FileSource::readAt(off64_t offset, void *data, size_t size) } return size; +#endif } status_t FileSource::getSize(off64_t *size) { std::lock_guard lock(m_lock); - if (!m_file.is_open()) { + if (!isOpen()) { return NO_INIT; } diff --git a/sdk/extractor/FileSource.h b/sdk/extractor/FileSource.h index 2b61aed7a..5b1df3bda 100644 --- a/sdk/extractor/FileSource.h +++ b/sdk/extractor/FileSource.h @@ -2,6 +2,8 @@ #include "DataSource.hpp" #include "NonCopyable.hpp" +#include +#include #include #include #include @@ -25,8 +27,17 @@ class FileSource : public DataSource, public NonCopyable { private: std::string getExtensionName(const std::string &filename); + bool isOpen() const; + private: + // Windows 上用宽字符路径打开(_wfopen),POSIX 用 std::ifstream。 + // 原因是 std::ifstream 只接受窄字符路径,而 UTF-8 → ANSI(CP_ACP) 的转换在非中文系统上 + // 会把无法映射的字符替换成 '?',中文名文件因此打不开(2026-11-07 Windows CI 抓到)。 +#ifdef _WIN32 + std::FILE *m_fp; +#else std::ifstream m_file; +#endif int64_t m_offset; int64_t m_length; std::mutex m_lock; diff --git a/sdk/test/unit/test_extractor.cpp b/sdk/test/unit/test_extractor.cpp index c37f3fb32..2d15542fa 100644 --- a/sdk/test/unit/test_extractor.cpp +++ b/sdk/test/unit/test_extractor.cpp @@ -6,7 +6,14 @@ #include #include #include +#include +// Utf8Path.h 在 Windows 上会带上 ,那里把 ERROR 定义成 0, +// 会撞上本文件后面的 StreamDecoderState::ERROR,就地撤掉这个宏。 +#ifdef ERROR +#undef ERROR +#endif #include +#include #include #include #include @@ -245,6 +252,50 @@ TEST_SUITE("FileSource") REQUIRE(source.initCheck() == sdk_utils::NO_INIT); } + // 2026-11-07:文件名必须按宽字符路径打开。旧实现是 UTF-8 → ANSI(CP_ACP) → std::ifstream, + // 这一步有损:CP_ACP 表示不了的字符会被换成 '?',文件直接打不开。中文名在 en-US 系统上、 + // emoji 在任何代码页下都会中招(Windows CI 首轮就是这么红的)。 + // 这里故意用 emoji 命名,使这条用例在中文和英文 Windows 上都能复现旧实现的失败。 + TEST_CASE("Non-ANSI filename opens through the wide path") + { + const std::string name = "file_source_\xF0\x9F\x98\x80.bin"; // U+1F600 + const std::string path = createUnitFilePath(name); + +#ifdef _WIN32 + // 建文件同理:窄路径写不出来,必须用宽字符。 + const std::wstring widePath = sdk_utils::toWideString(path.c_str()); + std::FILE *raw = _wfopen(widePath.c_str(), L"wb"); + REQUIRE(raw != nullptr); + REQUIRE(std::fwrite("ABCD", 1, 4, raw) == 4); + std::fclose(raw); +#else + { + std::ofstream output(path.c_str(), std::ios::binary); + REQUIRE(output.good()); + output.write("ABCD", 4); + } +#endif + + { + FileSource source(path.c_str()); + CHECK(source.initCheck() == sdk_utils::OK); + + off64_t size = 0; + CHECK(source.getSize(&size) == sdk_utils::OK); + CHECK(size == 4); + + char buffer[5] = { 0 }; + CHECK(source.readAt(0, buffer, 4) == 4); + CHECK(std::string(buffer) == "ABCD"); + } + +#ifdef _WIN32 + _wremove(sdk_utils::toWideString(path.c_str()).c_str()); +#else + std::remove(path.c_str()); +#endif + } + TEST_CASE("Path without extension returns no init") { const std::string path = createUnitNoExtensionPath("file_source_no_extension_case"); diff --git a/sdk/utils/Utf8Path.h b/sdk/utils/Utf8Path.h index ac32b408b..a4824c09a 100644 --- a/sdk/utils/Utf8Path.h +++ b/sdk/utils/Utf8Path.h @@ -27,19 +27,39 @@ inline std::string toUtf8String(const boost::filesystem::path &p) #endif } -// Convert UTF-8 string to native encoding for std::ifstream / fopen +#ifdef _WIN32 +// Convert UTF-8 string to the Windows native wide string. +// 打开文件应该走这条路径:宽字符与系统代码页无关,中文 / 日文 / emoji 之类的名字 +// 在任何语言的 Windows 上都能命中(见 FileSource 的 _wfopen)。 +inline std::wstring toWideString(const char *utf8) +{ + if (!utf8 || !utf8[0]) + return std::wstring(); + int wsize = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); + if (wsize <= 0) + return std::wstring(); + std::wstring ws(static_cast(wsize - 1), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8, -1, &ws[0], wsize); + return ws; +} +#endif + +// Convert UTF-8 string to native encoding for narrow-char APIs (std::ifstream / fopen) // Windows: UTF-8 → ANSI (local codepage), Linux: pass-through +// +// 注意:Windows 上这一步是 UTF-8 → ANSI(CP_ACP),**有损**。CP_ACP 随系统区域设置变化, +// 中文系统(936)能表示中文而英文系统(1252)不能,映射不上的字符会被替换成 '?', +// 文件名随之失效。2026-11-07 Windows CI 就是因为这里在 en-US runner 上把中文名素材 +// 打成 "?????.ogg" 而整片测试变红。**不要再用它打开文件**,改用 toWideString + _wfopen。 inline std::string toNativeString(const char *utf8) { #ifdef _WIN32 if (!utf8 || !utf8[0]) return std::string(); // UTF-8 → wstring - int wsize = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); - if (wsize <= 0) + const std::wstring ws = toWideString(utf8); + if (ws.empty()) return std::string(); - std::wstring ws(static_cast(wsize - 1), L'\0'); - MultiByteToWideChar(CP_UTF8, 0, utf8, -1, &ws[0], wsize); // wstring → ANSI int asize = WideCharToMultiByte(CP_ACP, 0, ws.c_str(), -1, nullptr, 0, nullptr, nullptr); if (asize <= 0) From 9b68a61dd8ad50c887f02ec92906dd94b0c50708 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 13:33:41 +0800 Subject: [PATCH 05/12] =?UTF-8?q?build:=20=E4=BF=AE=E6=AD=A3=20Windows=20C?= =?UTF-8?q?I=20=E7=BC=93=E5=AD=98=E5=9B=9E=E5=86=99=E8=A2=AB=E9=9A=90?= =?UTF-8?q?=E5=BC=8F=E7=8A=B6=E6=80=81=E6=A3=80=E6=9F=A5=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - if 里不含任何状态检查函数时,Actions 会自动套一层 success(), 于是「测试红」会让 Save 3rdparty dist cache 连带跳过, 而这正是它要救的场景(run 42341337832:构建成功、缓存没写回) - 显式加 !cancelled(),只要第三方库构建成功就回写 - aqtinstall.log 落进 .gitignore,避免 CI 上污染 git status --- .github/workflows/sdk-windows.yml | 6 +++++- .gitignore | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml index 54b334a36..715e7ec82 100644 --- a/.github/workflows/sdk-windows.yml +++ b/.github/workflows/sdk-windows.yml @@ -140,8 +140,12 @@ jobs: # 冷缓存才需要回写;actions/cache 的 post 步骤只在 job 成功时保存, # 这里显式保存,避免「构建成功但后续测试红」时整轮 90 分钟白跑。 # 条件挂在 setup 的 outcome 上:只有第三方库真的构建成功才值得缓存。 + # + # !cancelled() 不能省:if 里不出现任何状态检查函数时,Actions 会自动套一层 + # 隐式 success(),于是「测试红」会让本步骤连带被跳过——正好是它要救的场景 + # (2026-11-07 run 42341337832 就是这样,构建成功但缓存没写回,白等一轮冷构建)。 - name: Save 3rdparty dist cache - if: steps.setup.outcome == 'success' && steps.dist.outputs.cache-hit != 'true' + if: (!cancelled()) && steps.setup.outcome == 'success' && steps.dist.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: sdk/3rdparty/dist/windows_x86_64_gcc_debug diff --git a/.gitignore b/.gitignore index c721eedf2..ecbd221c4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ build-coverage/ __pycache__/ *.pyc ui_shot_*.png +# setup_windows_env.ps1 里的 aqt 会把安装日志落在仓库根,CI 上会污染 git status +aqtinstall.log /log/ From 02a79eec793a9ba6d04e6701077c9bace71bcfd8 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 13:37:38 +0800 Subject: [PATCH 06/12] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20CI=20?= =?UTF-8?q?=E9=9A=90=E5=BC=8F=20success()=20=E4=B8=8E=20Windows=20?= =?UTF-8?q?=E5=AE=BD=E5=AD=97=E7=AC=A6=E8=B7=AF=E5=BE=84=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - if 里不含状态检查函数时 Actions 会自动套 success(),缓存回写要显式 !cancelled() - Windows 上非 ASCII 文件名按 _wfopen + toWideString 打开,toNativeString 有损 --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 52b2d0c59..aff7b7988 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,12 @@ cmake --build build --target package_portable 缓存命中约 10~15 分钟,冷缓存 40~90 分钟;改动 `sdk/3rdparty` 的构建输入(CMakeLists / build_option / toolchain / ffmpeg 与 boost 构建脚本)会让缓存失效。Windows 任务用脚本装好的 CMake 3.31.6 绝对路径, 因为 runner 自带的 CMake 4.x 对 `cmake_minimum_required(3.5)` 直接报错。 +- **`if:` 不含状态检查函数时,Actions 会自动套一层隐式 `success()`**。所以「构建成功就回写缓存」这类 + 步骤必须显式写 `(!cancelled()) && ...`,否则前面的用例一红它就跟着被跳过(2026-11-07 踩过: + 冷构建 24 分钟做完、缓存没写回,下一轮又白等一次冷构建)。 +- **Windows 上非 ASCII 文件名必须按宽字符打开**:`sdk/extractor/FileSource.cpp` 用 `_wfopen` + + `sdk_utils::toWideString`;`toNativeString` 是 UTF-8 → ANSI(`CP_ACP`) 的有损转换, + 在 en-US runner 上中文变 `?`,只保留给确实需要 ANSI 的老 API。不要再用它打开文件。 - `sdk/cosmos/` 是 header-only 工具区;规则不够特殊,暂不单独挂子 AGENTS。 - `music/` 里包含真实媒体样本;涉及 extractor / decode 的改动,优先补 `sdk_media_smoke` 或 `sdk_extractor_spec_tests`。 - 用户口头说“提交一版”时,默认含义是:`git add` + `git commit` + `git push`;除非用户显式缩小范围,否则按这三个步骤执行。 From bab4563060d369b32c1b46bb96c685901d38cefb Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 13:57:59 +0800 Subject: [PATCH 07/12] =?UTF-8?q?build:=20=E4=BF=AE=E6=AD=A3=20Windows=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=BA=A6=E4=B8=8A=E6=8A=A5=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E7=9A=84=E8=BF=90=E8=A1=8C=E6=97=B6=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 直接起 UnitTests.exe 时只补了 3rdparty 的 bin,缺 Qt 与 MinGW 运行时; 本地 A/B 实测:只补 3rdparty → 127 且零输出,三处都补 → 15 行矩阵数值齐全 - pipefail 下 grep 无匹配也返回 1,与 exe 起不来在日志里分不开, 故先把 rc 与完整输出落盘再筛 - 该步骤纯观测,改 continue-on-error,环境问题不阻塞 PR --- .github/workflows/sdk-windows.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml index 715e7ec82..aa1c52de6 100644 --- a/.github/workflows/sdk-windows.yml +++ b/.github/workflows/sdk-windows.yml @@ -128,14 +128,21 @@ jobs: # 覆盖度矩阵的 declared / measured 只在断言失败时才由 doctest 打印,通过时数值不可见。 # 用 -s 打开成功断言输出,把 Windows 侧实测毫秒数留在日志里,供与 Linux 侧逐项核对 # (两侧必须落在同一容差内,见 doc/format-support.md)。与 Linux workflow 的同名步骤对齐。 - # 这里直接起 exe,不经过 add_test 烘焙的 PATH,所以要自己补 3rdparty 的 DLL 目录。 + # 这里直接起 exe,不经过 add_test 烘焙的 PATH,所以要自己补齐运行时 DLL 目录: + # 只补 3rdparty 时本地实测 UnitTests.exe 以 127 秒退且零输出(缺 Qt / MinGW 运行时), + # 而 pipefail 下 grep 无匹配同样返回 1,两者在日志里分不开。故先把 exe 的 rc 与输出 + # 完整落盘再筛,失败也不阻塞 PR(与 Linux 侧同名步骤的观测定位一致)。 - name: Report coverage matrix numbers + continue-on-error: true shell: bash run: | set -o pipefail - export PATH="$PWD/sdk/3rdparty/dist/windows_x86_64_gcc_debug/bin:$PATH" - ./build/sdk/test/UnitTests.exe -ts=MediaCoverage -s \ - | grep -E "declared |coverage matrix covered" + QT="$(cygpath -u "$QT_ROOT_PATH")" + export PATH="$(cygpath -u 'sdk/3rdparty/dist/windows_x86_64_gcc_debug/bin'):$QT/5.14.2/mingw73_64/bin:$QT/Tools/mingw730_64/bin:$PATH" + ./build/sdk/test/UnitTests.exe -ts=MediaCoverage -s > coverage.log 2>&1 + echo "UnitTests rc=$? lines=$(wc -l < coverage.log)" + head -20 coverage.log + grep -E "declared |coverage matrix covered" coverage.log # 冷缓存才需要回写;actions/cache 的 post 步骤只在 job 成功时保存, # 这里显式保存,避免「构建成功但后续测试红」时整轮 90 分钟白跑。 From 56ce14534d0205b89c1a035b238259d09b66f4a2 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 14:00:53 +0800 Subject: [PATCH 08/12] =?UTF-8?q?build:=20=E4=BF=AE=E6=AD=A3=20setup=20?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E7=9A=84=E5=8F=82=E6=95=B0=20splat=EF=BC=8C?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8B=BC=E5=9D=8F=E4=B8=8B=E8=BD=BD=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数组 splat 会把 -SkipThirdParty 按位置塞进第一个未绑定参数, 实测 \$CmakeVersion 变成 -SkipThirdParty,URL 拼坏后 404 - 改用哈希表 splat,named + switch 都按名字绑定 - 本地 scriptblock 复现两种写法,确认后者不再污染 CmakeVersion --- .github/workflows/sdk-windows.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml index aa1c52de6..0775eb43a 100644 --- a/.github/workflows/sdk-windows.yml +++ b/.github/workflows/sdk-windows.yml @@ -63,18 +63,29 @@ jobs: # 脚本本身是仓库里已验证的 Windows 一键环境(CMake 3.31.6 + Qt 5.14.2 + MinGW 7.3.0 # + 可选的三方库构建)。这里不另写一套安装逻辑,避免 CI 与本地环境漂移。 # 缓存命中时跳过三方库构建;根工程配置脚本总会做。 + # + # 必须用**哈希表** splat:脚本的参数块里 $QtRoot / $ToolsRoot / $CmakeVersion ... + # 都是位置参数,数组 splat($extra = @(); $extra += "-SkipThirdParty")会把那个字符串 + # 按位置塞进第一个未绑定参数,实测 $CmakeVersion 变成 "-SkipThirdParty", + # 下载 URL 拼成 v-SkipThirdParty/cmake--SkipThirdParty-windows-x86_64.zip 然后 404。 - name: Setup toolchain (Qt / MinGW / CMake) and 3rdparty id: setup shell: pwsh run: | - $extra = @() + $params = @{ + QtRoot = $env:QT_ROOT + ToolsRoot = $env:TOOLS_ROOT + Jobs = 4 + QtMirror = 'https://download.qt.io' + PyPIIndex = 'https://pypi.org/simple' + } if ("${{ steps.dist.outputs.cache-hit }}" -eq "true") { Write-Host "3rdparty dist cache hit: skip building dependencies" - $extra += "-SkipThirdParty" + $params.SkipThirdParty = $true } else { Write-Host "3rdparty dist cache miss: building dependencies from source" } - ./scripts/setup_windows_env.ps1 -QtRoot $env:QT_ROOT -ToolsRoot $env:TOOLS_ROOT -Jobs 4 -QtMirror https://download.qt.io -PyPIIndex https://pypi.org/simple @extra + ./scripts/setup_windows_env.ps1 @params # 每个 step 都是新进程,脚本内改的 $env:Path 不会传给后续步骤,必须显式导出。 # 注意顺序:这些 PATH 必须在配置根工程之前就位——sdk/test/CMakeLists.txt 会把 From 9d53c7b9f676e4925d0d6a15d73f7cb38ab0a121 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 14:20:32 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=E4=BA=A4=E4=BA=92=E7=BB=84?= =?UTF-8?q?=E5=89=A9=E4=BD=99=205=20=E5=A4=84=E6=95=B4=E5=BA=93=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E6=94=B9=E7=94=A8=E4=B8=80=E6=AC=A1=E6=80=A7=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - next / addMusicDir / seek / stop / pause 五条用例只用到一两首曲子, 却都扫整个 music/(20 个文件头),冷 cache 的 runner 上会撞 5s 上限 - 改为 TempMediaDir + 复制 music.wav,耗时从秒级降到 0.07s 量级 - 已做过两次红验证:清空素材目录时 2+9 条变红,把 next() 打桩后仍红 --- sdk/test/unit/test_player_integration.cpp | 53 ++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index b284d4ede..6e34af3ec 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -292,11 +292,22 @@ TEST_SUITE("MusicPlayer Track Navigation") return; } + // 两曲目录:本用例只需要「下一首确实能切」。整库扫描要读 20 个文件的头, + // 冷 cache 的 CI runner 上 5s 内拿不到曲目数(与 #72 修过的两条同源)。 + TempMediaDir temp("nextadvance"); + for (int i = 0; i < 2; ++i) { + const std::string name = std::string("track") + static_cast('1' + i) + ".wav"; + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file(name))) { + skipCase("cannot synthesize two-track playlist"); + return; + } + } + MockMusicPlayerListener listener; std::string logDir = "./log"; MusicPlayer player(&listener, logDir); - player.addMusicDir(fixture.mediaDir()); + player.addMusicDir(temp.dir()); if (!waitFor([&player]() { return player.getMusicCount() > 1; }, 5000)) { skipCase("playlist has fewer than two tracks"); return; @@ -342,11 +353,22 @@ TEST_SUITE("MusicPlayer Playlist Operations") return; } + // 两曲目录:这里要验的是「扫描目录能装进列表」,不是「20 种格式都能扫」。 + // 用整库会让耗时被 I/O 主导,且冷 cache 的 runner 上可能撞 20s 上限(见 #72)。 + TempMediaDir temp("adddir"); + for (int i = 0; i < 2; ++i) { + const std::string name = std::string("track") + static_cast('1' + i) + ".wav"; + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file(name))) { + skipCase("cannot synthesize two-track playlist"); + return; + } + } + MockMusicPlayerListener listener; std::string logDir = "./log"; MusicPlayer player(&listener, logDir); - player.addMusicDir(fixture.mediaDir()); + player.addMusicDir(temp.dir()); auto start = std::chrono::steady_clock::now(); while (listener.list_change_count == 0) { @@ -576,11 +598,18 @@ TEST_SUITE("MusicPlayer Interactive Paths") return; } + // 单曲目录:seek 只需要一首够长的曲目,整库扫描只会加 I/O 且可能撞 5s 超时。 + TempMediaDir temp("seekmiddle"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + MockMusicPlayerListener listener; std::string logDir = "./log"; MusicPlayer player(&listener, logDir); - player.addMusicDir(fixture.mediaDir()); + player.addMusicDir(temp.dir()); if (!waitFor([&player]() { return player.getMusicCount() > 0; }, 5000)) { skipCase("playlist empty"); return; @@ -944,10 +973,17 @@ TEST_SUITE("MusicPlayer Interactive Paths") return; } + // 单曲目录:停机语义只需要一首可播的曲目,整库扫描会撞 5s 超时(见 #72)。 + TempMediaDir temp("stoppos"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + MockMusicPlayerListener listener; std::string logDir = "./log"; MusicPlayer player(&listener, logDir); - player.addMusicDir(fixture.mediaDir()); + player.addMusicDir(temp.dir()); if (!waitFor([&player]() { return player.getMusicCount() > 0; }, 5000)) { skipCase("playlist empty"); return; @@ -1002,10 +1038,17 @@ TEST_SUITE("MusicPlayer Interactive Paths") return; } + // 单曲目录:幂等性只需要一首可播的曲目,整库扫描会撞 5s 超时(见 #72)。 + TempMediaDir temp("idempotent"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + MockMusicPlayerListener listener; std::string logDir = "./log"; MusicPlayer player(&listener, logDir); - player.addMusicDir(fixture.mediaDir()); + player.addMusicDir(temp.dir()); if (!waitFor([&player]() { return player.getMusicCount() > 0; }, 5000)) { skipCase("playlist empty"); return; From a6af35100589840420cc000660e334bed26fac20 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 14:20:32 +0800 Subject: [PATCH 10/12] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=AE=9E=E7=8E=B0=E7=8E=AF=E5=A2=83=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=88=E5=8E=9F=20.worktrees/=5Fshared/ENV.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原文被 .gitignore 排除、从未入库,worktree 删除后会永久丢失 - 归档时逐字节核对与原文一致,并加「哪些还有效」对照表(5 条坑全部仍然成立) --- doc/parallel-implementation-env.md | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 doc/parallel-implementation-env.md diff --git a/doc/parallel-implementation-env.md b/doc/parallel-implementation-env.md new file mode 100644 index 000000000..b497b7120 --- /dev/null +++ b/doc/parallel-implementation-env.md @@ -0,0 +1,126 @@ +# 并行实现的环境与协议(坏输入 effort,历史归档) + +> **历史文档(2026-11-07 归档)** +> +> 本文件原为 `.worktrees/_shared/ENV.md`,是 2026-11-03 坏输入地图(#22)并行实现期间, +> 给所有 implementer 共用的环境协议。相关 worktree 目录即将清理,原文若不归档会随目录一起丢失。 +> +> **分支名与目录已经过时**:`feat/bad-input-spec`、`bad-input/<票号>` 分支和 +> `.worktrees/t34`、`t35`、`t37` 等目录均已合并或废弃,不要照第三节的命令直接执行。 +> **第三节「五个坑」仍然有效**,跨平台并行开发时最容易踩。 +> +> 当前构建与测试命令以根 `README.md` / `AGENTS.md` 为准;测试分组见 +> `sdk/test/CMakeLists.txt`(2026-11-06 起按耗时拆为四组 ctest)。 + +## 哪些还有效(2026-11-07 复核) + +| 条目 | 状态 | +|---|---| +| `dist` 需要目录联接(worktree 里没有 3rdparty 构建产物,缺了 config 直接 `FATAL_ERROR`) | 仍有效。**反向风险已确认**:`t34/t35/t37` 的 `sdk/3rdparty/dist` 是指回主仓库的 junction,用资源管理器或 `rm -rf` 删目录会跟进删掉主仓库的 `dist` | +| 配置时的 `PATH` 会被烘焙进 `add_test`(`sdk/test/CMakeLists.txt` 的 `SDK_TEST_PATH`) | **仍有效**。改了 PATH 必须重新跑一次 `cmake -S . -B build`,否则测试报 `0xc0000135`,而手动跑同一个 exe 却是好的 | +| `ctest` 不在 PATH 里,要用绝对路径 | 仍有效 | +| Git Bash 里 `./UnitTests.exe` 偶发 `exit=127` 且 stdout 全空 | **仍有效**,那不是通过,是没跑起来 | +| 用例名用英文(GBK 控制台下中文 `-tc=` 会静默 0 匹配) | 仍有效 | +| 建联接用 PowerShell,不要 `cmd //c` / `mklink` | 仍有效 | +| `-j4` 而不是 `-j8`(多个 implementer 并行编译) | 视并行度而定,单人开发可以 `-j8` | + +已过时:集成分支名、`ctest -R sdk_unit_tests` 跑全量(现在是 `sdk_unit_tests` / `sdk_badinput_tests` / +`sdk_streaming_tests` / `sdk_interaction_tests` 四组)、`.worktrees/_shared/` 共享笔记机制。 + +--- + +以下为原文,除本节外未改动。 + +# SoundBridge 坏输入实现:环境与协议 + +所有 implementer 共用这一份。先读完再动手,这里的每一步都已经实跑验证过。 + +## 绝对路径 + +| 用途 | 路径 | +|---|---| +| 主仓库 | `D:\workspace\SoundBridge` | +| **集成分支** | `feat/bad-input-spec` | +| 规格(唯一真源) | `D:\workspace\SoundBridge\doc\bad-input-spec.md` | +| 共享笔记(可读可写) | `D:\workspace\SoundBridge\.worktrees\_shared\` | +| 你的 worktree | `D:\workspace\SoundBridge\.worktrees\t<票号>` | + +## 一、建立你的 worktree + +```bash +cd /d/workspace/SoundBridge +git worktree add .worktrees/t<票号> -b bad-input/<票号> feat/bad-input-spec +``` + +`dist` 被 `.gitignore` 排除,worktree 里没有第三方库,而根 `CMakeLists.txt` 找不到它会直接 `FATAL_ERROR`。 +用目录联接指回主仓库(**必须**,否则配置就挂): + +```powershell +New-Item -ItemType Junction -Path "D:\workspace\SoundBridge\.worktrees\t<票号>\sdk\3rdparty\dist" -Target "D:\workspace\SoundBridge\sdk\3rdparty\dist" +``` + +## 二、配置与构建 + +**配置命令必须带 `PATH` 和 `QT_ROOT_PATH`**(原因见第三节第 2 条): + +```bash +cd /d/workspace/SoundBridge/.worktrees/t<票号> +export PATH="/d/Qt/Tools/mingw730_64/bin:/d/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH" +export QT_ROOT_PATH=D:/Qt +D:/tools/cmake-3.31.6-windows-x86_64/bin/cmake.exe -S . -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_TOOLCHAIN_FILE=D:/workspace/SoundBridge/.worktrees/t<票号>/cmake/toolchain/toolchain.windows_x86_64_mingw.cmake \ + -DCMAKE_MAKE_PROGRAM=D:/Qt/Tools/mingw730_64/bin/mingw32-make.exe \ + -G "MinGW Makefiles" +``` + +构建(全量约 2 分钟,增量约 3 秒): + +```bash +D:/tools/cmake-3.31.6-windows-x86_64/bin/cmake.exe --build build -j4 +``` + +`-j4` 是**故意的**:机器 8 核,同时会有两三个 implementer 在编译,别用 `-j8` 互相抢。 + +## 三、测试 + +```bash +D:/tools/cmake-3.31.6-windows-x86_64/bin/ctest.exe --test-dir build -R sdk_unit_tests --output-on-failure +``` + +单跑一个套件(快): + +```bash +cd build/sdk/test && ./UnitTests.exe -ts=BadInput +``` + +**五个坑,踩了会浪费半小时:** + +1. **`dist` 联接**:见第一节。缺了报 `3rdparty/dist/... not exist, please build 3rdparty`。 +2. **配置时的 `PATH` 会被烘焙进测试命令**。`sdk/test/CMakeLists.txt:62-65` 把 `$ENV{PATH}` 拼进 `SDK_TEST_PATH`,`add_test` 又用 `cmake -E env "PATH=..."` **替换**整个 PATH。所以配置时 PATH 里没有 Qt 的两个 bin,测试就会以 `0xc0000135`(找不到 DLL)失败——而**手动跑同一个 exe 却是好的**,极易误判成代码问题。改了 PATH 后要重新跑一次 `cmake -S . -B build` 让它重新烘焙。 +3. **`ctest` 不在 PATH 里**,用上面的全路径。 +4. **`cmd //c`、`mklink` 在 Git Bash 里不可靠**,建联接用 PowerShell 工具,不要 `cmd //c`。 +5. **Git Bash 里 `./UnitTests.exe` 偶发 `exit=127` 且 stdout 全空**——那不是测试通过,是没跑起来。遇到空输出换 PowerShell 工具重跑。 + +## 四、改代码前必须知道的项目约定 + +- **不改 `sdk/3rdparty/`**,一行业务问题都不要通过改供应商代码解决。 +- **C++ 用例名用英文**:中文名在 GBK 控制台下 `-tc=` 过滤会静默 0 匹配。 +- 错误分两层:内部 `sdk_utils::status_t`,对外 `soundbridge::ErrorCode` / `sdk::ErrorCode`。 +- 编码/路径转换集中在 `sdk/utils/Utf8Path.h`,不要往业务代码里散 `#ifdef _WIN32`。 +- 仓库用 CRLF 行尾,改写已有文件时不要改行尾风格。 + +## 五、TDD 怎么做 + +1. 先写一个**当前会红**的用例,红的原因必须是「缺陷还在」,不是「还没实现」。 +2. 素材用**字节手术在临时目录合成**,不入库、跑完删;读源走 `FileSource`(`music/` 里有中文名文件)。 +3. 跑到绿,再跑整套 `ctest -R sdk_`,确认没打翻别的。 +4. 判据看规格第 2 节总表与各节「判据」小节,**不要自己发明容差**。 + +## 六、完成协议 + +1. 只改你这张票覆盖的东西。碰到票外的缺陷,记进 `_shared/findings-<票号>.md`,不要顺手改。 +2. 提交到自己的分支 `bad-input/<票号>`,信息用 `fix:` / `feat:` 前缀 + 中文分点。 +3. **报告完成前先 `git merge feat/bad-input-spec`**,把集成分支的最新进展并进来,冲突自己解。 +4. **不要 push 远端**,也不要碰 `feat/bad-input-spec` 本身——合并由编排方做。 +5. 报告里写清楚:改了什么文件、新用例叫什么、跑过什么命令、结果如何。不要复述规格。 From af6373b2f361bd71ec66c946dc818d0539298ce7 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 14:20:37 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=E9=9D=99=E9=9F=B3=E5=A1=AB?= =?UTF-8?q?=E5=85=85=E8=AE=A1=E6=95=B0=E7=94=A8=E4=BE=8B=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=B4=A7=E8=BD=AE=E8=AF=A2=E6=8D=95=E8=8E=B7=20SEEKING=20?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SEEKING 窗口长度固定:解码线程进入后 sleep 12ms;而主循环每 5ms 采一次样, Windows 上这个睡眠可能落到 15.6ms 计时器粒度,12ms 窗口可以整段落在两次采样之间 - CI 上实测漏过一次:silenceFills == 0,而 seek 本身成功 - 发出 seek 后按 ~30ns 粒度紧轮询直到窗口打开并补记一次;本地 10/10 在窗口打开后 几微秒内命中,不再依赖 sleep 的实际粒度 - 失败信息补上 seekIssued / seekCompleted,下次真红了能一眼分清「没进窗口」与「seek 没落地」 --- sdk/test/unit/test_streaming_budget.cpp | 36 +++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/sdk/test/unit/test_streaming_budget.cpp b/sdk/test/unit/test_streaming_budget.cpp index f0e114a55..4811b4d23 100644 --- a/sdk/test/unit/test_streaming_budget.cpp +++ b/sdk/test/unit/test_streaming_budget.cpp @@ -91,6 +91,35 @@ TEST_SUITE("StreamingBudget") return decoder.state() == StreamDecoderState::SEEKING; } + // 发起 seek,并紧轮询直到 SEEKING 窗口打开,在窗口打开处补记一次静音填充。 + // + // 为什么要紧轮询:SEEKING 窗口的长度是固定的——解码线程进入 SEEKING 后会 + // sleep 12 ms(AudioStreamDecoder.cpp 的 seek 分支),窗口就这么长。而主循环 + // 是 `sleep_for(5ms)` 一次采样,Windows 上这个睡眠可能落到 15.6 ms 的计时器粒度, + // 于是 12 ms 的窗口可以整个落在两次采样之间,「窗口内计数」就变成了依赖机器 + // 定时器行为的断言(CI 上实测漏过一次:silenceFills == 0,而 seek 本身是成功的)。 + // 这里从 seek 发出的同一刻开始逐次读 state()(单次约 30 ns),本地 10/10 都在 + // 窗口打开后几微秒内命中,不再依赖 sleep 的实际粒度。 + // + // 只补记「一次」是刻意的:计数器口径是「消费端在窗口内交付了一块静音」, + // 紧轮询里的每一次读不是一次交付,多记会把数值变成与机器速度相关的随机量。 + // 窗口内后续的记账仍由主循环按原口径完成。 + void seekAndCatchSilenceWindow(AudioStreamDecoder &decoder, uint64_t targetMs) + { + decoder.seekToMs(targetMs); + // 上限 200 万次读:只防「seek 因为超界之类的原因根本不落地」时死循环, + // 正常路径都在窗口打开的第一时间就 return。 + for (int spin = 0; spin < 2000000; ++spin) { + if (decoder.state() == StreamDecoderState::SEEKING) { + decoder.noteSilenceFill(); + return; + } + if (decoder.positionMs() == targetMs) { + return; // 窗口已经整段过去(或本来就无需等待),交给主循环判落地 + } + } + } + // 一次播放的可选驱动参数。 struct PlaybackPlan { // 消费端取走这么多字节后发起一次 seek;0 表示不 seek。 @@ -140,7 +169,7 @@ TEST_SUITE("StreamingBudget") seeked = true; counting = false; observation.seekIssued = true; - decoder.seekToMs(plan.seekTargetMs); + seekAndCatchSilenceWindow(decoder, plan.seekTargetMs); } if (!counting) { @@ -896,7 +925,10 @@ TEST_SUITE("StreamingBudget") PlaybackObservation seekRun = playWholeTrack(fixture.mediaDir(), kShortOgg, ".ogg", 60000, seeking); REQUIRE(seekRun.opened); - CHECK_MESSAGE(seekRun.metrics.silenceFills > 0, "SEEKING 窗口里的静音填充没有计数"); + CHECK_MESSAGE(seekRun.metrics.silenceFills > 0, + "SEEKING 窗口里的静音填充没有计数(seekIssued=" + << seekRun.seekIssued << ", seekCompleted=" << seekRun.seekCompleted + << ")"); CHECK_MESSAGE(seekRun.bytes > 0, "seek 之后没有继续交付 PCM"); } From f7dde48197e89b716747c3bacb9774364983d53f Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sat, 7 Nov 2026 14:29:40 +0800 Subject: [PATCH 12/12] =?UTF-8?q?build:=20=E5=88=A0=E6=8E=89=20Windows=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8C=E7=9A=84=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E8=AF=8A=E6=96=AD=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 素材文件名诊断是排「非 ANSI 文件名打不开」时加的脚手架,问题已由宽字符路径修掉, 并留下 test_extractor 的专门用例锁住 - 它 continue-on-error 仍会在 UI 上留一个红叉注解,绿工作流里看起来像红 - 顺带把文件头的成本数字换成实测值(冷 24m05s / 热 7m33s),coverage.log 进 .gitignore --- .github/workflows/sdk-windows.yml | 23 +++-------------------- .gitignore | 2 ++ 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/sdk-windows.yml b/.github/workflows/sdk-windows.yml index 0775eb43a..7e562b4d1 100644 --- a/.github/workflows/sdk-windows.yml +++ b/.github/workflows/sdk-windows.yml @@ -4,9 +4,9 @@ name: sdk-windows # 2026-11-06 那个 seek 差异是 Linux 先撞出来的,反方向同样成立。 # # 成本说明:3rdparty(Boost / FFmpeg / SDL2 / FLAC / Ogg / Vorbis / zlib / g3log / g3sinks) -# 在 Windows 上只能源码构建,首次约 40~90 分钟。因此把 dist 目录整体缓存, -# 缓存命中时走 setup_windows_env.ps1 的 -SkipThirdParty,单次约 10~15 分钟。 -# 缓存键只包含第三方构建输入,业务代码改动不会让缓存失效。 +# 在 Windows 上只能源码构建,冷缓存一轮约 24 分钟;把 dist 目录整体缓存后, +# 热缓存一轮约 7~8 分钟(2026-11-07 实测:run 42341337832 冷 24m05s, +# run 42346594655 热 7m33s)。缓存键只包含第三方构建输入,业务代码改动不会让缓存失效。 on: push: branches: @@ -113,23 +113,6 @@ jobs: & "$env:CMAKE_BIN\cmake.exe" --build build --parallel if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - # 诊断用:素材里有一批中文文件名,Windows runner 上这批文件打不开而 ASCII 名的正常。 - # 先确认是「检出时文件名被改写」还是「打开时的编码转换失败」——列目录 + 看 git 是否认为有改动。 - - name: Diagnose media filenames (non-gating) - if: always() - shell: bash - continue-on-error: true - run: | - echo "=== git status(若检出改写了文件名,这里会显示删除)===" - git status --porcelain | head -30 - echo "=== git ls-files(仓库里的名字)===" - git ls-files music | head -25 - echo "=== 磁盘上的名字(python repr,看真实码点)===" - python -c "import os; [print(repr(n)) for n in sorted(os.listdir('music'))[:25]]" - echo "=== 纯 ASCII 与中文各挑一个做 open 测试 ===" - python -c "import io;print(io.open('music/music.wav','rb').read(4))" - python -c "import io,glob;f=[p for p in glob.glob('music/*.ogg')];print(f);print(io.open(f[0],'rb').read(4) if f else 'no ogg')" - - name: Run SDK tests shell: pwsh run: | diff --git a/.gitignore b/.gitignore index ecbd221c4..7addcebfa 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ __pycache__/ ui_shot_*.png # setup_windows_env.ps1 里的 aqt 会把安装日志落在仓库根,CI 上会污染 git status aqtinstall.log +# Windows 工作流「Report coverage matrix numbers」步骤的输出落盘 +coverage.log /log/