关于 multithreaded-task-migration
面向 MSBuild 任务维护者的迁移指南,说明如何改造任务以适配多线程执行,并通过兼容性审查保留原有可观察行为。
主要功能
- 01使用 MSBuildMultiThreadableTask 与 IMultiThreadableTask
- 02通过 TaskEnvironment 处理路径和环境变量
- 03安全配置进程启动信息
- 04审查输出、错误和异常兼容性
- 05验证批处理与并发行为
适用场景
- 01将现有 MSBuild 任务改为可多线程执行
- 02检查路径绝对化是否改变输出或日志
- 03为迁移后的任务设计兼容性测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ msbuild --skill multithreaded-task-migration安装后在 Claude Code 中输入/multithreaded-task-migration确认已加载。
详细介绍
将 MSBuild 任务迁移到多线程 API
MSBuild 的多线程执行模型要求任务避免使用全局进程状态(工作目录、环境变量)。线程安全的任务通过 MSBuildMultiThreadableTask 声明此能力,并使用 IMultiThreadableTask 中的 TaskEnvironment 作为安全替代方案。
迁移步骤
步骤 1:更新任务类声明
a. 确保实现任务的类带有 MSBuildMultiThreadableTask 特性。
b. 仅当任务需要使用 TaskEnvironment API(路径绝对化、环境变量、进程启动)时,才实现 IMultiThreadableTask。如果任务不执行文件或环境操作(例如存根类),仅添加特性即可。
[MSBuildMultiThreadableTask]
public class MyTask : Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
...
}
注意:[MSBuildMultiThreadableTask] 的 Inherited = false,因此必须在每个具体类上添加该特性,不能只添加在基类上。
步骤 2:在文件操作前将路径转换为绝对路径
使用文件系统 API 前,必须先通过 TaskEnvironment.GetAbsolutePath() 将所有路径字符串转换为绝对路径。这样会根据项目目录而非进程工作目录解析路径。
AbsolutePath absolutePath = TaskEnvironment.GetAbsolutePath(inputPath);
if (File.Exists(absolutePath))
{
string content = File.ReadAllText(absolutePath);
}
AbsolutePath 结构体:
Value— 绝对路径字符串OriginalValue— 保留输入路径(用于错误消息和[Output]属性)- 可隐式转换为
string,以兼容 File/Directory API GetCanonicalForm()— 解析..路径段并规范化分隔符(参见 陷阱 5)
注意:对于 null/空输入,GetAbsolutePath() 会抛出 ArgumentException。有关兼容性影响,请参见 陷阱 3 和 陷阱 6。
步骤 3:替换环境变量 API
| 迁移前(不安全) | 迁移后(安全) |
|---|---|
Environment.GetEnvironmentVariable("VAR"); | TaskEnvironment.GetEnvironmentVariable("VAR"); |
Environment.SetEnvironmentVariable("VAR", "v"); | TaskEnvironment.SetEnvironmentVariable("VAR", "v"); |
步骤 4:替换进程启动 API
| 迁移前(不安全——继承进程状态) | 迁移后(安全——使用任务的隔离环境) |
|---|---|
var psi = new ProcessStartInfo("tool.exe"); | var psi = TaskEnvironment.GetProcessStartInfo(); |
psi.FileName = "tool.exe"; |
更新单元测试
内置 MSBuild 任务现在会使用由 MultiProcessTaskEnvironmentDriver 支持的默认实现来初始化 TaskEnvironment。测试创建内置任务实例时,不再需要手动设置 TaskEnvironment。对于没有默认初始化器、但实现了 IMultiThreadableTask 的自定义任务或第三方任务,请设置 TaskEnvironment = TaskEnvironmentHelper.CreateForTest()。
应避免使用的 API
| 类别 | API | 替代方案 |
|---|---|---|
| 禁止使用 | Environment.Exit、FailFast、Process.Kill、ThreadPool.SetMin/MaxThreads、Console.* | 返回 false、抛出异常或使用 Log |
| 使用 TaskEnvironment | Environment.CurrentDirectory、Get/SetEnvironmentVariable、Path.GetFullPath、ProcessStartInfo | 参见步骤 2-4 |
| 需要绝对路径 | File.*、Directory.*、FileInfo、DirectoryInfo、FileStream、StreamReader/Writer | 先转换为绝对路径(文件系统 API) |
| 需要审核 | Assembly.Load*、Activator.CreateInstance* | 检查是否存在版本冲突 |
实用说明
关键:追踪所有路径字符串的使用
追踪每个路径字符串在所有方法调用和赋值中的流向,找出它流入文件系统操作的所有位置——包括内部可能使用文件系统 API 的辅助方法。
- 找出每个路径字符串(例如,
item.ItemSpec、函数参数) - 追踪其在下游所有方法调用中的流向
- 在任何会触及文件系统的代码路径之前,先转换为绝对路径
- 面向用户的输出(日志、错误)使用
OriginalValue——参见 罪 2
批处理中的异常处理
批量处理(遍历文件)时,GetAbsolutePath() 在某个路径无效时抛出异常,会中止整个批次。请匹配原始任务的错误语义:
bool success = true;
foreach (ITaskItem item in SourceFiles)
{
try
{
AbsolutePath path = TaskEnvironment.GetAbsolutePath(item.ItemSpec);
ProcessFile(path);
}
catch (ArgumentException ex)
{
Log.LogError("Invalid path '{0}': {1}", item.ItemSpec, ex.Message);
success = false;
}
}
return success;
优先使用 AbsolutePath,而非字符串
始终使用 AbsolutePath——需要时它会隐式转换为 string。避免先转换为 string,再转回来。
TaskEnvironment 不是线程安全的
如果任务在内部创建多个线程,请同步对 TaskEnvironment 的访问。每个任务实例都有自己的环境,因此任务之间无需同步。
参考资料
兼容性红队手册
迁移后,请检查行为兼容性。每个可观察到的差异在证明无害之前都应视为 bug。
可观察行为 = Execute() 返回值、[Output] 属性值、错误/警告消息内容、异常类型、写入的文件,以及执行的代码路径。
6 个致命的兼容性陷阱
这些是在 MSBuild 任务迁移中发现的真实 bug。每个 bug 都曾出现在初始的“测试通过”代码中,且测试结果为绿色。
陷阱 1:输出属性受到污染
绝对路径泄漏到用户/其他任务使用的 [Output] 属性中。
// BROKEN: ManifestPath was "bin\Release\app.manifest", now "C:\repo\bin\Release\app.manifest"
AbsolutePath abs = TaskEnvironment.GetAbsolutePath(Path.Combine(OutputDirectory, name));
ManifestPath = abs; // implicit string conversion!
// CORRECT: separate original form from absolutized path
string originalPath = Path.Combine(OutputDirectory, name);
AbsolutePath outputPath = TaskEnvironment.GetAbsolutePath(originalPath);
ManifestPath = originalPath; // [Output]: original form
document.Save((string)outputPath); // file I/O: absolute path
检测方法:对每个 [Output] 属性向前追踪——它是否曾被赋值为 AbsolutePath?
陷阱 2:错误消息中的路径膨胀
错误消息显示的是绝对路径,而非用户原始输入。
// BROKEN: "Cannot find 'C:\repo\app.manifest'" instead of "Cannot find 'app.manifest'"
AbsolutePath abs = TaskEnvironment.GetAbsolutePath(path);
Log.LogError("Cannot find '{0}'", abs); // implicit conversion!
// CORRECT: use OriginalValue
Log.LogError("Cannot find '{0}'", abs.OriginalValue);
检测方法:搜索每个 Log.LogError/LogWarning/LogMessage——是否有任何参数是 AbsolutePath?
陷阱 3:改变控制流的 Null 合并
添加 ?? "" 会悄无声息地吞掉旧代码依赖其进行错误处理的异常。
// BEFORE: Path.GetDirectoryName("C:\") → null → Path.Combine(null, x) → ArgumentNullException
// → task fails with an exception / error logged → Execute() returns false
// BROKEN: ?? "" added "for safety"
string dir = Path.GetDirectoryName(fileName) ?? string.Empty;
// Path.Combine("", x) succeeds silently → no error → Execute() returns TRUE!
检测方法:对添加的每个 ??,问自己:“之前它为 null 时会发生什么?”如果会抛出异常并被捕获 → 你的 ?? 就是个 bug。
陷阱 4:Try-Catch 作用域不匹配
在 try 块中调用 GetAbsolutePath(),会导致转换后的绝对路径值在 catch 块中不可用。catch 中的辅助方法(如 LockCheck)随后会使用原始的非绝对路径。
// CORRECT: hoist above try so catch can use it too
AbsolutePath abs = TaskEnvironment.GetAbsolutePath(OutputManifest.ItemSpec);
try {
WriteFile(abs);
} catch (Exception ex) {
string lockMsg = LockCheck.GetLockedFileMessage(abs); // absolute → correct file
Log.LogError("Failed: {0}", OutputManifest.ItemSpec, ...); // original → user-friendly
}
检测方法:对 try 中的每个 GetAbsolutePath,检查 catch 块是否需要使用转换后的绝对路径值。
陷阱 5:规范化不匹配
GetAbsolutePath 不会规范化路径。Path.GetFullPath 会执行两项操作:转换为绝对路径并规范化(解析 ..、统一分隔符)。如果旧代码使用 Path.GetFullPath 生成字典键、进行比较或显示路径,就必须添加 .GetCanonicalForm():
// GetAbsolutePath("foo/../bar") → "C:\repo\foo/../bar" (NOT canonical)
// Path.GetFullPath("foo/../bar") → "C:\repo\bar" (canonical)
// BROKEN for dictionary keys — "C:\repo\foo\..\bar" ≠ "C:\repo\bar"
var map = items.ToDictionary(p => (string)TaskEnvironment.GetAbsolutePath(p.ItemSpec), ...);
// CORRECT
var map = items.ToDictionary(
p => (string)TaskEnvironment.GetAbsolutePath(p.ItemSpec).GetCanonicalForm(),
StringComparer.OrdinalIgnoreCase);
检测方法:查找所有使用路径作为键的 Dictionary/HashSet/ToDictionary,以及旧代码调用 Path.GetFullPath 的所有位置。如果规范化很重要,就添加 .GetCanonicalForm()。
陷阱 6:异常类型变更
旧代码在文件缺失时抛出 FileNotFoundException;新代码在检查文件之前,会从 GetAbsolutePath("") 抛出 ArgumentException。按异常类型筛选的自定义 catch 块可能无法捕获此异常。(ExceptionHandling.IsIoRelatedException 会捕获 ArgumentException,但特定任务的处理程序可能不会。)
检测方法:对于每个 GetAbsolutePath,检查旧代码在参数为 null 或空时抛出什么异常,以及调用代码是否有按类型筛选的 catch 块。
红队审计协议
阶段 1:追踪每一处变更
对于每个修改的行:修改前的运行时值是什么?修改后呢?值会流向哪里(输出、日志、文件路径、字典键)?每个目标位置的可观察行为是否完全一致?
阶段 2:Null、空值和边界输入分析
| 输入 | GetAbsolutePath | 旧行为 | 是否一致? |
|---|---|---|---|
null | ArgumentException | 不一 | ❓ |
"" | ArgumentException | 不一 | ❓ |
"C:\"(根目录) | 有效 | 有效 | ✅ 通常一致 |
"." | "C:\repo\."(未规范化) | 如果使用 GetFullPath,则为 "C:\repo" | ❌ 可能不一致 |
"foo\..\bar" | "C:\repo\foo\..\bar" | 如果使用 GetFullPath,则为 "C:\repo\bar" | ❌ 可能不一致 |
| 已是绝对路径 | 原样传递 | 原样传递 | ✅ |
Path.GetDirectoryName → Path.Combine 链:
| 输入 | GetDirectoryName 返回值 | Path.Combine(result, x) |
|---|---|---|
"C:\" | null | 抛出 ArgumentNullException |
"" | ""(.NET Fx)/ null(.NET Core+) | 正常工作 / 抛出异常 ⚠️ |
"file.resx"(无目录) | "" | 正常工作 |
请在 net472 和 net10.0 上验证行为。
阶段 3:下游影响
- 输出属性:此任务的
[Output]被什么使用?使用方会对其进行比较、显示,还是将其用作路径? - 写入的文件:文件内容是否会发生变化?(例如,包含内嵌路径的 XML)
- 辅助方法:
LockCheck、ManifestWriter等是否会在内部解析相对路径? - 错误代码:MSBuild 错误代码(MSBxxxx)必须保持一致。
阶段 4:并发
ProjectDirectory值不同的两个任务不会相互干扰- 不会写入静态字段(线程间共享)
- 所有文件操作都使用绝对化路径
兼容性测试矩阵
[Task] × [Input Type] × [Assertion]
Inputs: relative path, absolute path, null, empty, ".." segments, root "C:\",
forward slashes, trailing separator, UNC path, 260+ char path
Assertions: Execute() return value, [Output] exact string, error message content,
exception type, file location, file content
签核检查清单
- 每个具体类上都有
[MSBuildMultiThreadableTask](不能只标在基类上,因为Inherited=false) - 使用
TaskEnvironmentAPI 的类实现了IMultiThreadableTask,并使用默认初始值设定项= TaskEnvironment.Fallback - 每个
[Output]属性的字符串值都与迁移前完全一致 - 每个
Log.LogError/LogWarning:消息中的路径都与迁移前一致(使用OriginalValue) - 每个
GetAbsolutePath调用:null/空值的异常行为都与旧代码路径一致 - 每个以路径为键的字典/集合:都保留了规范化行为(
GetCanonicalForm()) - 每个 try-catch:catch 块在需要时都能获取绝对化的值
- 每个新增的
??或?.:都已确认不会吞掉之前会抛出的异常 -
AbsolutePath不会意外出现在面向用户的字符串中 - 已追踪辅助方法中使用未绝对化路径调用 File API 的情况
- 自定义任务的测试会设置
TaskEnvironment = TaskEnvironmentHelper.CreateForTest()(内置任务有默认值) - 跨框架:已在 net472 和 net10.0 上测试
- 并发执行:使用不同项目目录的两个任务都能产生正确结果
- 不使用禁止的 API(
Environment.Exit、Console.*等)