mcpField
M
multithreaded-task-migration

multithreaded-task-migration

github.com/dotnet开发5.6k37 次安装1 天前更新
代码仓库

关于 multithreaded-task-migration

面向 MSBuild 任务维护者的迁移指南,说明如何改造任务以适配多线程执行,并通过兼容性审查保留原有可观察行为。

主要功能

  1. 01使用 MSBuildMultiThreadableTask 与 IMultiThreadableTask
  2. 02通过 TaskEnvironment 处理路径和环境变量
  3. 03安全配置进程启动信息
  4. 04审查输出、错误和异常兼容性
  5. 05验证批处理与并发行为

适用场景

  1. 01将现有 MSBuild 任务改为可多线程执行
  2. 02检查路径绝对化是否改变输出或日志
  3. 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
使用 TaskEnvironmentEnvironment.CurrentDirectory、Get/SetEnvironmentVariable、Path.GetFullPath、ProcessStartInfo参见步骤 2-4
需要绝对路径File.*、Directory.*、FileInfo、DirectoryInfo、FileStream、StreamReader/Writer先转换为绝对路径(文件系统 API)
需要审核Assembly.Load*、Activator.CreateInstance*检查是否存在版本冲突

实用说明

关键:追踪所有路径字符串的使用

追踪每个路径字符串在所有方法调用和赋值中的流向,找出它流入文件系统操作的所有位置——包括内部可能使用文件系统 API 的辅助方法。

  1. 找出每个路径字符串(例如,item.ItemSpec、函数参数)
  2. 追踪其在下游所有方法调用中的流向
  3. 在任何会触及文件系统的代码路径之前,先转换为绝对路径
  4. 面向用户的输出(日志、错误)使用 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旧行为是否一致?
nullArgumentException不一❓
""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:下游影响
  1. 输出属性:此任务的 [Output] 被什么使用?使用方会对其进行比较、显示,还是将其用作路径?
  2. 写入的文件:文件内容是否会发生变化?(例如,包含内嵌路径的 XML)
  3. 辅助方法:LockCheck、ManifestWriter 等是否会在内部解析相对路径?
  4. 错误代码:MSBuild 错误代码(MSBxxxx)必须保持一致。
阶段 4:并发
  1. ProjectDirectory 值不同的两个任务不会相互干扰
  2. 不会写入静态字段(线程间共享)
  3. 所有文件操作都使用绝对化路径

兼容性测试矩阵

[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)
  • 使用 TaskEnvironment API 的类实现了 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.* 等)

评论

支持 Markdown