From 686dd9335ac22fb22af3c996c39d851607fefb5a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 18:44:51 +0800 Subject: [PATCH] =?UTF-8?q?test(core):=20mtime=20=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=98=BE=E5=BC=8F=20set=5Fmodified=EF=BC=8Cu?= =?UTF-8?q?pdate-e2e=20=E5=A4=B1=E8=B4=A5=E5=8D=87=E6=B3=A8=E8=A7=A3?= =?UTF-8?q?=EF=BC=88M275=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree_snapshot_sees_new_nodes_and_mtime_changes 的「同内容重写」时间维度断言在 Windows/NTFS 上不可靠:同一系统时钟 tick 内的两次写、或时间戳只在句柄关闭时按 粗粒度落盘,都会让 mtime 与写之前逐字节相同 —— main 上 test (windows-latest) 偶发红在这行 assert_ne!(check-run 130965734990)。改成显式时间戳: File::options().write(true).open + set_modified(基线 + 5s),值由测试指定、 与 tick/落盘粒度无关;不用 sleep、不用重试。用例原本的目的(快照必须看得见 mtime 变化)保持不变,另附 4s 下界断言把「我们推的那一下」与 stat 抖动分开。 ci.yml 的 update-e2e 步骤原本是裸 cargo test,失败只留一句 exit code 1、 check-run 上零注解,匿名侧等于信息为零。改成与 test 作业同款:pwsh + Tee-Object 存 $RUNNER_TEMP 日志、失败把 panicked at / error / test result: FAILED / failures: 行以 ::error:: 输出(% → %25)、打印并 exit $LASTEXITCODE (cargo 真码,不是 Tee 的码)。步骤名 update-e2e 与 --test update_e2e_test 逐字保留(check_test_source_off.py 的 ALLOWED_STEP 钉死这两点)。 行数不变(ci.yml 397 → 398,≤400):注释块等价压缩腾出的行给运行时逻辑。 --- .github/workflows/ci.yml | 59 ++++++++++--------- .../tests/launch/natives_dry_run_cases.rs | 46 ++++++++++++--- 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1904916..0f58960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,8 +109,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.os }} - # `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude), - # 所以这条命令不会拉起 slint / usvg / i-slint-*。 + # `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 exclude)⇒ 不拉起 slint / usvg / i-slint-*。 - run: cargo clippy --workspace --all-targets -- -D warnings test: @@ -187,16 +186,15 @@ jobs: # 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。 # # ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ---- - # M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码 - # 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读 - # (匿名 403),但**注解匿名可读**(`GET /check-runs//annotations` → 200,M144 实证)。三件事 - # 各对准一种「看不见」: - # 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test - # binary 就退出),后面还有没有第二条失败完全看不到; - # 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧 - # 唯一能取到的失败正文; - # 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」 - # 分开——这正是 M144 没定的那一半。 + # M144 的 `test (windows)` 只留 `Process completed with exit code 1`(cargo 自己的失败码是 **101**) + # ⇒ 那个 1 来源不明;job log 要登录才能读(匿名 403),但**注解匿名可读** + # (`GET /check-runs//annotations` → 200,M144 实证)。三件事各对准一种「看不见」: + # 1) `--no-fail-fast`:cargo 默认首个失败即停(M144 实测 Linux 只跑 7 个 test binary 就退出), + # 后面还有没有第二条失败看不到; + # 2) `error` / `failures:` / panicked 行升成 `::error::`:拿不到 job log 时注解是匿名侧唯一正文 + # (M275 把同一手法补到末尾的 `update-e2e` 步骤); + # 3) 显式打印 cargo 退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、 + # 零诊断」分开——这正是 M144 没定的那一半。 - name: cargo test --workspace --all-targets(ubuntu,失败升注解) if: runner.os == 'Linux' run: | @@ -364,20 +362,14 @@ jobs: path: ${{ runner.temp }}/smoke/ if-no-files-found: warn - # M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / - # 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。 - # - # 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` + - # `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` - # 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿 - # (r1 复审 P2-4)。 - # - # 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在 - # `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test - # update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。 - # - # 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与 - # panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。 + # M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / 篡改包体 / + # 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。只在 Windows:真实产物是裸 `.exe` + + # `portable-exe`,Linux/macOS 是 `.tar.gz` + `portable-zip`(`ensure_self_replaceable` 直接拒), + # 在 ubuntu 上跑绿只证明「世上不存在的 linux portable-exe 能装上」(r1 复审 P2-4)。 + # 步骤名 `update-e2e` 与 run 里的 `--test update_e2e_test` 被 `packaging/check_test_source_off.py` 的 + # `ALLOWED_STEP` 钉死(`update-test-source` 只许在这一项里出现)——改这里的 name / run 之前先读那段注释。 + # 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions` / 优化 / panic 行为 + # 都不同,用 debug 跑这条 E2E 就失去它存在的理由)。能跑起来比跑得快重要。 update-e2e: name: update-e2e (windows x64) runs-on: windows-latest @@ -391,7 +383,16 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: windows-latest + # M275:裸 `cargo test` 失败只留一句 `exit code 1`、check-run 上零注解(匿名侧等于信息为零),所以照 + # test 作业同一手法升注解:`$LASTEXITCODE` 是 cargo 的真码(Tee-Object 是 cmdlet,不改它)、`%` → `%25`。 - name: update-e2e - run: > - cargo test --release -p unlucky-cli --features update-test-source - --test update_e2e_test --test update_e2e_tamper_test + shell: pwsh + run: | + $log = Join-Path $env:RUNNER_TEMP 'update-e2e.log' + cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test 2>&1 | Tee-Object -FilePath $log + $code = $LASTEXITCODE; Write-Host "cargo exit=$code" + if ($code -ne 0) { + Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' | Select-Object -First 40 | + ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" } + } + exit $code diff --git a/crates/core/tests/launch/natives_dry_run_cases.rs b/crates/core/tests/launch/natives_dry_run_cases.rs index beb32de..02e537d 100644 --- a/crates/core/tests/launch/natives_dry_run_cases.rs +++ b/crates/core/tests/launch/natives_dry_run_cases.rs @@ -5,6 +5,7 @@ //! 也抓不住「目录本来就在、但被重写/清空」的情形。 use std::path::{Path, PathBuf}; +use std::time::Duration; use unlucky_core::launch::natives::{self, NativeJar, NativesPlan}; @@ -19,8 +20,9 @@ use crate::prelude::*; /// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑 /// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。 /// -/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的 -/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。 +/// 时间戳口径:**精度 ≠ 可靠性**。NTFS / ext4 的 mtime 字段精度是 100ns 级,但「两个操作能不能 +/// 分辨」取决于时间戳**何时被采样、以多大粒度更新**(同内容立即重写在 NTFS 上可能落在同一系统 +/// 时钟 tick 内、句柄关闭时才按粗粒度落盘),所以快照只在「有人显式改过时间戳」时才是判据。 /// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。 fn tree_snapshot(root: &Path) -> Vec { fn line(meta: &std::fs::Metadata, relative: &str) -> String { @@ -211,14 +213,22 @@ fn dry_run_after_real_extract_reports_ready_without_writing() { /// /// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响 /// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。 -/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变, -/// 不依赖父目录时间戳的合并行为)。 +/// +/// 时间维度也**不**靠「同内容立刻重写」:NTFS 上 mtime 不保证「每次写都变」——同一个系统 +/// 时钟 tick 内的两次写、或时间戳只在句柄关闭时按粗粒度落盘,都会让重写后的 mtime 与写之前 +/// **逐字节相同**。M275 的实际失败正是这一条:main 上 `test (windows-latest)` 偶发红在这行 +/// `assert_ne!`(`crates\core\tests\launch\natives_dry_run_cases.rs:241`),本机永远复现不出来。 +/// `sleep` + 重试轮询只是把概率压低(把「不可靠观察」换成「不可靠观察 + 更长的墙钟」), +/// 不是消除。确定性判据是**显式时间戳**:`File::set_modified` 把 mtime 直接写成 +/// 「基线 + 5s」——值由我们指定,与时钟 tick / 落盘粒度无关;快照若看不见它,说明快照真的 +/// 丢了时间维度,而不是运气不好。 #[test] fn tree_snapshot_sees_new_nodes_and_mtime_changes() { let root = temp_dir("natives-snapshot-probe"); let version_dir = root.join("versions/1.16.5"); std::fs::create_dir_all(&version_dir).expect("建版本目录"); - std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位"); + let manifest = version_dir.join("1.16.5.json"); + std::fs::write(&manifest, b"{}").expect("放清单占位"); let baseline = tree_snapshot(&version_dir); // 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。 @@ -230,8 +240,20 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() { ); assert_ne!(baseline, with_dir); - // 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。 - std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位"); + // 2) 时间维度:显式把清单文件的 mtime 推到基线之后(节点集合不变 ⇒ 差异只可能来自时间戳)。 + // 不改内容长度(那动的是 size,不是 mtime)、不 `sleep`、不重试。 + let before = std::fs::metadata(&manifest) + .expect("清单元数据") + .modified() + .expect("基线 mtime"); + let pushed = before + Duration::from_secs(5); + let file = std::fs::File::options() + .write(true) + .open(&manifest) + .expect("打开清单占位"); + file.set_modified(pushed).expect("把 mtime 推到基线之后"); + drop(file); + let rewritten = tree_snapshot(&version_dir); assert_eq!( with_dir.len(), @@ -242,4 +264,14 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() { with_dir, rewritten, "快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿" ); + // 差异必须是我们推的那一下,而不是两次 `stat` 的偶然抖动(粗粒度文件系统会把 5s 取整到 + // 4s 或 6s,所以判据是「至少前进 4s」而不是「恰好 5s」)。 + let after = std::fs::metadata(&manifest) + .expect("清单元数据") + .modified() + .expect("推进后的 mtime"); + assert!( + after.duration_since(before).expect("mtime 应当前进了") >= Duration::from_secs(4), + "mtime 应当被推到基线之后:{before:?} → {after:?}" + ); }