multithreaded-task-migration について
MSBuild タスクの保守担当者向けに、マルチスレッド実行への移行方法を解説します。TaskEnvironment の安全な利用と、動作互換性を確認する監査手順を扱います。
主な機能
- 01MSBuildMultiThreadableTask と IMultiThreadableTask の利用
- 02TaskEnvironment によるパスと環境変数の処理
- 03安全なプロセス起動情報の設定
- 04出力・エラー・例外の互換性監査
- 05バッチ処理と並行動作の確認
活用シーン
- 01既存の MSBuild タスクをマルチスレッド実行に対応
- 02パスの絶対化による出力やログへの影響確認
- 03移行後の互換性テストの設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dotnet/ msbuild --skill multithreaded-task-migrationインストール後、Claude Code で/multithreaded-task-migrationを入力して読み込まれたか確認します。
詳細
マルチスレッド API への MSBuild タスクの移行
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()—..セグメントを解決し、区切り文字を正規化します(Sin 5 を参照)
注意:GetAbsolutePath() は、入力が null または空の場合に ArgumentException をスローします。互換性への影響については、Sin 3 と Sin 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* | バージョンの競合を確認する |
実用上の注意
重要: すべてのパス文字列の使用箇所を追跡する
すべてのパス文字列を、すべてのメソッド呼び出しと代入を通して追跡し、ファイルシステム操作に流れ込むすべての箇所を特定してください。これには、内部的に File System API を使用する可能性のあるヘルパーメソッドも含まれます。
- すべてのパス文字列 (例:
item.ItemSpec、関数のパラメーター) を特定する - すべてのメソッド呼び出しを通して下流を追跡する
- ファイルシステムに触れるすべてのコードパスの前に絶対パス化する
- ユーザー向けの出力 (ログ、エラー) には
OriginalValueを使用する — 罪 2 を参照
バッチ操作における例外処理
バッチ処理 (ファイルの反復処理) では、1 つの不正なパスで 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 へのアクセスを同期してください。各タスクのインスタンスはそれぞれ独自の環境を持つため、タスク間の同期は不要です。
参考資料
互換性レッドチームプレイブック
移行後は、動作の互換性についてレビューしてください。観測可能なあらゆる差異は、そうでないと証明されるまでバグです。
観測可能な動作 = Execute() の戻り値、[Output] プロパティの値、エラー/警告メッセージの内容、例外の種類、書き込まれるファイル、およびどのコードパスが実行されるか。
6 つの致命的な互換性の罪
MSBuild タスクの移行中に見つかった実際のバグです。そのすべてが、テストがグリーンな「合格」した初期コードに混入していました。
罪 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 だったとき以前はどうなっていたか?」と問いかけてください。例外がスローされてキャッチされていたなら → あなたの ?? はバグです。
罪 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 は 2 つのことを行います: 絶対パス化と正規化 (.. の解決、区切り文字の正規化) です。古いコードがディクショナリのキー、比較、または表示に 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値を持つ 2 つのタスクが干渉しない - 静的フィールドへの書き込みがない (スレッド間で共有される)
- すべてのファイル操作が絶対化されたパスを使用している
互換性テストマトリックス
[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 の両方でテスト済み
- 並行実行: 異なるプロジェクトディレクトリを持つ 2 つのタスクが正しい結果を生成する
- 禁止された API (
Environment.Exit、Console.*など) がない