ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CoreCLR 测试配置决策流程:CLRTestKind 与兼容性开关的避坑清单

CoreCLR 测试配置决策流程:CLRTestKind 与兼容性开关的避坑清单

CoreCLR 测试配置决策流程:CLRTestKind 与兼容性开关的避坑清单

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

往src/tests里新增一个测试时,你真正要回答的只有四个问题:构建系统会不会编译它、会不会执行它、它会进入哪些执行模式、批量构建时它会被跑到哪一级为止?CLRTestKind、CLRTestPriority以及一组Incompatible属性,加上 stripe 条纹切分,就是 CoreCLR 测试配置的完整决策流程。

一个测试工程如何被构建系统识别:默认推断链

本节回答:csproj 里什么都不写时,构建系统会替你做出哪些决定,RunOnly 工程又如何找到自己要运行的可执行文件。

未声明 CLRTestKind 时会发生什么

所有测试工程统一从 src/tests/Directory.Build.targets 进入,文件开头的推断链(第 19-21 行)先按OutputType兜底:

<CLRTestKind Condition="'$(CLRTestKind)' == '' and '$(OutputType)' == 'Exe'">BuildAndRun</CLRTestKind> <CLRTestKind Condition="'$(CLRTestKind)' == ''">SharedLibrary</CLRTestKind> <CLRTestPriority Condition="'$(CLRTestPriority)' == ''">0</CLRTestPriority>

解读:可执行程序不写CLRTestKind就是BuildAndRun,类库则是SharedLibrary;优先级永远默认 0,也就是说测试只会被你显式降级,不存在升级。随后第 30-64 行的Choose块把四种种类映射为四个内部开关,决定编译源码、构建可执行文件、生成执行脚本、是否需要运行这四个动作的组合:

CLRTestKind编译源码构建可执行文件生成执行脚本需要运行
BuildOnly是是否否
RunOnly否否是是
BuildAndRun(默认)是是是是
SharedLibrary是否否否

BuildOnly适合只做编译期校验的工程,SharedLibrary适合被多个测试共用的托管库,BuildAndRun覆盖绝大多数普通测试。

RunOnly 工程如何定位要运行的产物

RunOnly的特殊之处在于它自己不编译任何东西,只负责生成执行脚本。第 66-69 行先算出"需要运行但不产出可执行文件"的组合,置位_CLRTestNeedsProjectToRun;接着第 84-88 行把你用<CLRTestProjectToRun>指定的依赖工程转成一条ProjectReference,保证被引用工程先构建完成;第 564 行再导入 src/tests/Common/runonly.targets 走专用构建路径。

真正拼接执行脚本的逻辑在 src/tests/Common/CLRTest.Execute.targets 的FetchExternalProperties目标(第 90-103 行),其中输入程序集的定位方式按种类分叉:

<InputAssemblyName Condition="'$(CLRTestKind)' == 'RunOnly'">$([MSBuild]::MakeRelative($(OutputPath), $(_CLRTestToRunFileFullPath)))</InputAssemblyName> <InputAssemblyName Condition="'$(CLRTestKind)' == 'BuildAndRun'">$(AssemblyName).dll</InputAssemblyName>

解读:BuildAndRun直接用自身$(AssemblyName).dll;RunOnly则先跨进程查询被引用工程的脚本目标,再用[MSBuild]::MakeRelative算出相对路径,作为InputAssemblyName写进 Batch(Windows)或 Bash(Unix)脚本。典型用法是同一个可执行文件配多组不同命令行参数,各自建一个 RunOnly 工程,避免重复编译。

CLRTestPriority 如何控制"构建到第几级"

优先级除了决定运行范围,还参与选择性构建。第 94-99 行的判定逻辑是:只有传入BuildAllProjects=true时才启用批量模式,此时仅当CLRTestPriority <= CLRTestPriorityToBuild的工程才把_WillCLRTestProjectBuild置为 true;第 126 行还会对优先级超限的工程跳过 IL targets 导入。运行语义上则是包含式的:你要跑 priority 2 的测试,priority 0、1 的工程会一并执行——所以最重要的测试应当恰好落在最小集合里,即 priority 0。工程文件里降级只需一行,比如<CLRTestPriority>1</CLRTestPriority>,仓库里GC/API/Frozen/Frozen.csproj就是这样的真实用例。

你的测试会出现在哪些执行模式里:兼容性开关全清单

本节回答:怎样把测试从 GCStress、JIT 压力、NativeAOT 等特定执行模式里摘出去。所有开关都写在 csproj 中,取值统一为true。

执行模式、注入方式与排除属性对照

执行模式触发/注入机制csproj 属性
GCStress执行脚本注入环境变量DOTNET_GCStress(值取自RunWithGcStress)GCStressIncompatible
JIT stressDOTNET_JitStress/DOTNET_JitStressRegs等环境变量JitOptimizationSensitive
NativeAOTTestBuildMode=nativeaot的构建变体NativeAotIncompatible
ilasm 往返执行脚本中的RunningIlasmRoundTrip开关,反汇编后再汇编回 ILIlasmRoundTripIncompatible(同时隐含ManagedIlasmRoundTripIncompatible)
可回收程序集(unloadability)独立测试通道UnloadabilityIncompatible
ReadyToRunR2R 测试通道 + crossgen2 编译R2RIncompatible

GCStress 的注入点可以在 src/tests/Common/CLRTest.Jit.targets 末尾看到(第 497-498 行):

<CLRTestEnvironmentVariable Condition="'$(RunWithGcStress)' != ''" Include="DOTNET_GCStress" Value="$(RunWithGcStress)" />

解读:压力参数本身是运行期通过CLRTestEnvironmentVariable项拼进执行脚本的环境变量,所以"排除"某种模式本质上是告诉脚本生成器:该模式下不要生成这个测试的执行入口。

两条会自动生效的联动规则

第一条来自CLRTest.Jit.targets的GetDisasmCheckData目标(第 108-118 行):只要任一Compile项标记了HasDisasmCheck=true,构建系统就会自动置GCStressIncompatible=true。原因是反汇编检查断言的是特定代码形态,GCStress 改变分配模式后 CHECK 断言必然失真,与其跑挂了再排查,不如直接屏蔽。

第二条在 src/tests/Common/CLRTest.CrossGen.targets 第 26 行:AlwaysUseCrossGen2=true会连带置IlasmRoundTripIncompatible=true,因为 crossgen2 产物与 ilasm 往返流程不兼容。

R2R 的两个高级属性:AlwaysUseCrossGen2 与 CrossGenTest

  • AlwaysUseCrossGen2=true:测试专门验证 crossgen2,任何测试模式下都走 AOT 编译。执行脚本里对应export RunCrossGen2=1与export CompositeBuildMode=1(Bash 版脚本第 61-64 行),进入复合(composite)编译流程。
  • CrossGenTest=false:即便整轮是 R2R 测试通道,该测试也不做标准 R2R 编译,只按普通方式运行。
  • 两者同时出现会被直接拒绝:第 47 行定义了一个 MSBuildError,文案为 "Setting both AlwaysUseCrossGen2 to true and CrossGenTest to false is non-sensical"。
  • 若设了R2RIncompatible=true又逢 R2R 通道,生成的脚本会打印SKIPPING EXECUTION BECAUSE the test is incompatible with ReadyToRun并exit 0——这是"主动跳过",不是失败。

从零建一个测试工程:端到端检查单

本节回答:一个新测试要经过哪些步骤才能被基础设施完整接受。

动笔之前的硬性规范

每个测试源文件头部必须带这两行许可证横幅:

// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license.

托管代码部分必须能在任何平台编译通过——CI 的做法是所有测试的托管部分先在 OSX 上统一构建,各目标平台只运行其中子集。因此你的代码里不能出现三种东西:依赖目标平台的条件编译、依赖目标平台的条件文件包含、依赖目标平台的<DefineConstants/>条件定义。平台差异只能放在运行期判断,或交给下面的禁用开关处理。

另外三条约定:测试用到的System.Private.CoreLib类型和方法必须在所有平台上可编译,不支持的平台用throw new PlatformNotSupportedException()的桩实现兜底;因已知 bug 而失败的测试要挂[ActiveIssue]特性;NuGet 依赖不写进各测试工程,而是集中加到 src/tests/Common/test_dependencies/test_dependencies.csproj(该工程自身就是BuildOnly,通过AddLibrariesToCoreRoot目标把库运行时文件汇集到 Core_Root,Directory.Build.targets第 488-491 行会导入它的 targets 到每个测试工程)。

从模板到首次运行的八步

  1. 把一个成熟测试(例如src/tests/Exceptions/Finalization/Finalizer.csproj)连同源文件复制到src/tests下的新目录。
  2. ⚠️删掉<AssemblyName>属性。保留它会干扰构建系统对测试产物的命名与定位假设。
  3. 声明<CLRTestKind>/<CLRTestPriority>,显式表达意图,别依赖推断。
  4. 用 xunit 的[Fact]特性写测试方法。Directory.Build.targets第 537-543 行会把 src/tests/Common/XUnitWrapperGenerator 以 Analyzer 形式的ProjectReference注入每个需要运行的 C# 测试工程——这个源生成器扫描所有Fact方法并从生成的Main里依次调用它们。
  5. 也可以自己写Main而不依赖源生成器,此时遵循退出码约定:成功返回 100,任何非 100 值都算失败。这与脚本侧一致:CLRTest.Execute.targets第 37 行定义了<CLRTestExitCode>默认值100。
static public int Main(string[] notUsed) { try { // 测试场景 } catch (Exception e) { Console.WriteLine($"Test Failure: {e}"); return 101; } return 100; }

解读:执行脚本最终比对的是进程退出码与CLRTestExitCode,自定义入口时只要守住"100 = 通过"这条线即可。

  1. 按需声明依赖:托管库用<ProjectReference Include="../ManagedDll.csproj" />;原生库用<CMakeProjectReference Include="../NativeDll/CMakeLists.txt" />,后者由Directory.Build.targets的ResolveCMakeNativeProjectReference识别后转为NativeProjectReference,构建完把原生二进制复制进测试输出目录。
  2. 执行构建。
  3. 按"重跑失败测试"的标准步骤验证新测试确实能跑、能失败。

需要在某些目标上整体禁用:两个开关

条件性禁用用CLRTestTargetUnsupported,例如<CLRTestTargetUnsupported Condition="'$(TargetArchitecture)' == 'arm64'">true</CLRTestTargetUnsupported>,效果是该目标下既不构建也不运行;无条件禁用构建用<DisableProjectBuild>true</DisableProjectBuild>。两者最终都汇入第 94-107 行的_WillCLRTestProjectBuild判定(分别对应第 100、101 行),判定为 false 后第 561 行导入 src/tests/Common/nobuild.targets,让后续构建目标全部空转——这是"被跳过"的最常见根因。

两类特殊的测试组织形态:回归测试与合并运行器

本节回答:不加新 csproj 怎么投喂回归测试;大批量测试组在 GC Stress 下超时怎么办。

回归测试源码:source-glob 直接并入,还是独立工程

JIT/Regression_ro_2/Regression_ro_2.csproj这类运行器会递归包含其目录下所有.cs文件,所以往src/tests/JIT/Regression_ro_2/Runtime_<issue_number>.cs丢一个文件就是一次完整的新增——不改编程文件,也不为单个源文件建目录。相关文件可以放子目录分组;被多个运行器共享的源码放在某个运行器目录里,由其他运行器显式包含。其他"源码编译型"回归运行器沿用同一约定,并保留各自的分区(partition)与编译设置。

反过来,需要独立编译参数、独立程序集、原生依赖或进程隔离的测试,必须拥有自己的工程,放在src/tests/JIT/Regression_2/Runtime_<issue_number>/:Regression_2.csproj采用递归发现工程的方式组织它们,而不是直接编译源码;Regression_3.csproj则类似地管辖Regression_3/下既有的GitHub_*、DevDiv_*等基于工程的测试。⚠️ 这两类形态的边界必须守住:独立工程的源码不要塞进 source-glob 运行器的目录,否则会被悄悄编译进错误的运行器。

合并运行器与 stripe 切分:大批量测试怎么在本地复现

以 src/tests/JIT/Methodical/Methodical_d1.csproj 为模板:工程用<HasMergedInTests>true</HasMergedInTests>声明合并属性,再用MergedWrapperProjectReference以通配符批量引入子工程,例如:

<MergedWrapperProjectReference Include="a*/**/*_d.??proj" /> <MergedWrapperProjectReference Include="b*/**/*_d.??proj" />

解读:这两行把 a~e 开头分区里所有_d.??proj子工程聚合进同一个运行器程序集,共享的公共源文件(如common/localloc_common.ilproj)用普通ProjectReference引入,少数需要直接并入的源码用显式Compile Include列出。

若这个运行器测试量巨大、GC Stress 下跑不完,就设<NumberOfStripesToUseInStress>(比如 10)。生效逻辑在Directory.Build.targets的GenerateMarkerFiles目标:合并运行器未显式指定时默认取 1(第 319-321 行),随后按 0..99 整数序列为前 N 个值各生成一个标记文件,命名形如<name>.MergedTestAssembly.<stripe>.<total>.MergedTestAssemblyForStress(第 426-429 行),CI 压力测试据此把大组切成多条条纹并行;超过 99 会直接报错 "Attempt to split into too many stripes. Either reduce stripe count, or add more entries to SequenceOfIntegers"(第 431 行)。

本地手动复现时,合并运行器接受一组标准命令行参数:

[testFilterString] [-stripe <whichStripe> <totalStripes>]

testFilterString是除-stripe外的任意字符串,目前仅支持dotnet test --filter的简单形式,即测试完全限定名的子串匹配。条纹控制有等价两条路径:命令行-stripe <whichStripe> <totalStripes>,或环境变量TEST_HARNESS_STRIPE_TO_EXECUTE(值必须写成.<whichStripe>.<totalStripes>的形式)。<whichStripe>从 0 开始编号,<totalStripes>是条纹总数——比如-stripe 2 10表示跑第 3 条(0 起算)共 10 条中的那一条。

当测试被跳过或行为异常:按症状定位 targets 的排查路径

本节回答:测试没构建、没跑、或者 R2R 行为怪异时,从哪个文件的哪个判定分支查起。

一张"症状 → 去处"的对照表:

观察到的症状先查的文件具体判定分支
整个工程没构建,产物目录为空src/tests/Directory.Build.targets_WillCLRTestProjectBuild(94-107 行):CLRTestTargetUnsupported、DisableProjectBuild、NativeAotIncompatible+ nativeaot、AlwaysUseCrossGen2+ mono/sanitizer/nativeaot 等都会置 false,随后导入 nobuild.targets 空转
RunOnly 工程报找不到程序集src/tests/Common/CLRTest.Execute.targetsFetchExternalProperties(90-103 行):CLRTestProjectToRun是否指向可构建工程、MakeRelative路径是否正确
应生成执行脚本却没有src/tests/Common/CLRTest.Execute.targetsGenerateExecutionScriptsInternal(105 行)的触发条件是_CLRTestNeedsToRun or _CLRTestBuildsExecutable,两者皆 false 则静默跳过
R2R 通道下脚本直接 exit 0src/tests/Common/CLRTest.CrossGen.targets第 50-55 行的跳过分支(R2RIncompatible),以及第 47 行的AlwaysUseCrossGen2与CrossGenTest=false冲突Error
反汇编断言意外失效src/tests/Common/CLRTest.Jit.targetsGetDisasmCheckData(108-118 行):HasDisasmCheck自动置GCStressIncompatible;GetDisasmCheck*Script还会在 JitStress/TailcallStress/TieredPGO 等环境下整体跳过 FileCheck
GCStress 环境变量没进脚本src/tests/Common/CLRTest.Jit.targets末尾(497-498 行)的CLRTestEnvironmentVariable注入点
应力测试切分不生效src/tests/Directory.Build.targetsGenerateMarkerFiles(318-431 行):IsMergedTestRunnerAssembly、NumberOfStripesToUseInStress与标记文件生成条件

CI 上看到的失败页面通常会把原始输出与退出码一起给出,排查时先对照"退出码 100 才算通过"的约定,再回到上表定位分支:

这张截图来自仓库 CI 工作流文档,展示了失败测试的典型呈现:原始输出、期望/实际值对比、退出码判定。对照本文的机制,退出码检查正是执行脚本末尾与CLRTestExitCode(默认 100)比对的结果,而 "SKIPPING EXECUTION BECAUSE ..." 一类文案则意味着脚本在判定分支处主动退出,属于"被跳过"而非"被失败"。

收尾:关键属性速查表

把前面几章的决策压缩成一张表:CLRTestKind决定构建形态,CLRTestPriority决定运行/构建范围,Incompatible系列决定执行模式准入,stripe 属性决定压力测试切分,退出码守住成功语义。

属性默认值作用
CLRTestKindBuildAndRun(OutputType=Exe时)构建形态:BuildOnly/RunOnly/BuildAndRun/SharedLibrary
CLRTestProjectToRun空RunOnly工程指向的真正被运行工程
CLRTestPriority0优先级;批量运行时 ≤ 该值的全部执行,需显式降级
CLRTestExitCode100视为成功的退出码
CLRTestTargetUnsupported空按条件在特定目标上禁构建、禁运行
DisableProjectBuild空无条件取消构建(导入 nobuild.targets)
GCStressIncompatible空(带反汇编检查时自动置位)排除 GCStress 通道
JitOptimizationSensitive空排除 JIT 压力运行
NativeAotIncompatible空排除 NativeAOT 构建变体
IlasmRoundTripIncompatible空(AlwaysUseCrossGen2时自动置位)排除 ilasm 往返;隐含ManagedIlasmRoundTripIncompatible
UnloadabilityIncompatible空排除可回收程序集测试
R2RIncompatible空排除 ReadyToRun 通道,脚本打印跳过文案后 exit 0
AlwaysUseCrossGen2空恒走 crossgen2 复合编译;与CrossGenTest=false冲突会报 Error
CrossGenTesttrue置false后 R2R 通道内也不做标准 R2R 编译
HasMergedInTests空声明合并测试运行器
NumberOfStripesToUseInStress1(合并运行器)GC Stress 条纹数,上限 99

掌握这张表之后,配置问题的本质就回到了第一章的推断链:每一个属性最终都汇入_WillCLRTestProjectBuild、四个种类开关或某段执行脚本生成逻辑。遇到"测试被跳过"、"脚本未生成"或"R2R 行为异常",沿着症状表反查对应的 targets 分支,通常几分钟内就能定位根因。

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进