From b9531cf6a92d525e7555a37779c8107afe5d3d90 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:34:35 +0100 Subject: [PATCH 1/6] Add static fields guidance to spec. --- .../specs/multithreading/thread-safe-tasks.md | 141 ++++++++++++++++++ src/Framework/IBuildEngine4.cs | 12 ++ 2 files changed, 153 insertions(+) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index a983fda8ef7..bf6d3f3e544 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -142,6 +142,147 @@ public bool Execute(...) } ``` +## Managing Static State Across Builds + +### Problem: Static State Leaks Across Builds + +MSBuild reuses worker nodes across builds by default. Any static field in a task that runs on a reused node retains its value from previous builds. Tasks that run exclusively on the main (scheduler) node were historically unaffected because the main process exited after each build. With MSBuild Server, the main node also persists, extending this problem to those tasks as well. In multithreaded builds, static fields introduce an additional risk: concurrent tasks sharing the same static field can cause race conditions. + +Static fields in tasks can: + +- **Leak data across builds** — a static cache populated during one build remains populated for subsequent builds on reused nodes, even if project state has changed. +- **Cause thread-safety issues** — in multithreaded builds, concurrent tasks sharing a static field can race unless the field is designed for concurrent access. + +Thread-safety of static fields is the task author's responsibility, same as in any multithreaded application. For the data leak problem, MSBuild provides `IBuildEngine4.RegisterTaskObject` — an API that lets tasks store objects with explicit, engine-managed lifetimes instead of relying on static fields. + +```csharp +void IBuildEngine4.RegisterTaskObject(object key, object obj, RegisteredTaskObjectLifetime lifetime, bool allowEarlyCollection); +object IBuildEngine4.GetRegisteredTaskObject(object key, RegisteredTaskObjectLifetime lifetime); +object IBuildEngine4.UnregisterTaskObject(object key, RegisteredTaskObjectLifetime lifetime); +``` + +The engine stores registered objects. All three methods are thread-safe and may be called concurrently from multiple tasks. If multiple tasks attempt to register an object with the same key concurrently, only the first registration takes effect — subsequent calls are ignored. When an object's lifetime expires, MSBuild calls `IDisposable.Dispose` on it if it implements `IDisposable`, then removes it. + +`RegisteredTaskObjectLifetime` controls when objects are disposed: + +| Lifetime | Disposed When | Use Case | +|----------|---------------|----------| +| `Build` | The build completes | Per-build caches and resources that must not leak across builds. | +| `AppDomain` | The MSBuild process exits | Objects that are safe to share across builds. | + +With MSBuild Server, `Build` lifetime objects are disposed between each build request, giving task authors the same isolation they previously got from process-level separation. + +### Example: Migrating a Static Cache + +A common pattern is a static `Dictionary` that caches expensive lookups across task invocations within a build: + +**Before — static cache that leaks across builds:** + +```csharp +public class MyTask : Task +{ + private static readonly Dictionary s_cache = new(); + + public override bool Execute() + { + ... + s_cache[key] = value; + ... + } +} +``` + +This cache persists across builds on reused nodes. It is also not thread-safe for concurrent access. + +**After — engine-managed lifetime:** + +```csharp +public class MyTask : Task +{ + private const string CacheKey = "MyNamespace.MyTask.Cache"; + + public override bool Execute() + { + var engine4 = (IBuildEngine4)BuildEngine; + + var cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); + if (cache is null) + { + engine4.RegisterTaskObject( + CacheKey, new ConcurrentDictionary(), + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: true); + // Re-read to get the authoritative instance in case another + // task registered first. + cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); + } + + cache[key] = value; + ... + } +} +``` + +The cache is now scoped to a single build and automatically discarded when the build completes. + +### Cleanup-on-Dispose Pattern + +When a static cache is used by utility classes or helper methods that do not have access to `IBuildEngine`, it cannot be replaced with a registered task object. Instead, the task may keep the static field and register a disposable wrapper that clears it when the build ends: + +```csharp +internal static class MyHelper +{ + // Static cache accessed by helper methods that have no IBuildEngine. + private static readonly ConcurrentDictionary s_cache = new(); + internal static void ClearCache() => s_cache.Clear(); +} + +public class MyTask : Task +{ + private const string CleanerKey = "MyNamespace.MyTask.CacheCleaner"; + + public override bool Execute() + { + // Register a one-time cleanup wrapper so the static cache is + // cleared when the build ends and does not leak into future builds. + var engine4 = (IBuildEngine4)BuildEngine; + if (engine4.GetRegisteredTaskObject(CleanerKey, RegisteredTaskObjectLifetime.Build) is null) + { + // If another task instance races ahead, only one registration wins. + // This is safe: at least one cleanup wrapper will be registered. + engine4.RegisterTaskObject( + CleanerKey, + new CacheCleanup(MyHelper.ClearCache), + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: false); + } + ... + } + + /// + /// Invokes a cleanup delegate when disposed. Register with + /// RegisterTaskObject so MSBuild calls Dispose at end of build. + /// + private sealed class CacheCleanup(Action onDispose) : IDisposable + { + public void Dispose() => onDispose(); + } +} +``` + +The same pattern can be applieed to third-party libraries that maintain their own static state — task may register a cleanup wrapper that calls the library's cache-clearing API. + +`allowEarlyCollection` need to be set to `false` because early collection would trigger the cleanup mid-build. The static cache would then continue accumulating entries for the remainder of the build with no end-of-build cleanup. + +### Guidelines for Task Authors + +1. **Set `allowEarlyCollection: true`** when the cached data can be safely recreated. This lets MSBuild reclaim memory under pressure. Use `false` only for objects that must survive the entire build (e.g., cleanup wrappers, long-lived connections). +2. **Use a stable, unique key.** A `const string` with the fully-qualified task name avoids collisions (e.g., `"MyNamespace.MyTask.Cache"`). +3. **Handle null returns.** `GetRegisteredTaskObject` returns null when no object is registered under the key, or when a previously registered object was disposed through early collection. +4. **Registered objects must be thread-safe** in multithreaded builds, since multiple task instances may retrieve and use the same object concurrently. + ## Appendix: Alternatives This appendix collects alternative approaches considered during design. diff --git a/src/Framework/IBuildEngine4.cs b/src/Framework/IBuildEngine4.cs index 4440a139d61..549fdb83f9e 100644 --- a/src/Framework/IBuildEngine4.cs +++ b/src/Framework/IBuildEngine4.cs @@ -51,6 +51,12 @@ public interface IBuildEngine4 : IBuildEngine3 /// manage limited process memory resources. /// /// + /// This method is thread-safe. If multiple threads concurrently attempt to register an object with the + /// same and , only the first registration takes effect + /// and subsequent registrations are ignored. Callers should use after + /// registration to obtain the authoritative instance. + /// + /// /// The thread on which the object is disposed may be arbitrary - however it is guaranteed not to /// be disposed while the task is executing, even if is set /// to true. @@ -72,6 +78,9 @@ public interface IBuildEngine4 : IBuildEngine3 /// The registered object, or null is there is no object registered under that key or the object /// has been discarded through early collection. /// + /// + /// This method is thread-safe and may be called concurrently from multiple tasks. + /// object GetRegisteredTaskObject(object key, RegisteredTaskObjectLifetime lifetime); /// @@ -83,6 +92,9 @@ public interface IBuildEngine4 : IBuildEngine3 /// The registered object, or null is there is no object registered under that key or the object /// has been discarded through early collection. /// + /// + /// This method is thread-safe and may be called concurrently from multiple tasks. + /// object UnregisterTaskObject(object key, RegisteredTaskObjectLifetime lifetime); } } From 45b62638fde4e5ed1fa448c51ff33d5388e6b7d0 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:15:23 +0100 Subject: [PATCH 2/6] Remove outdated line in example. --- documentation/specs/multithreading/thread-safe-tasks.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index bf6d3f3e544..968a81f5e9d 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -174,8 +174,6 @@ With MSBuild Server, `Build` lifetime objects are disposed between each build re ### Example: Migrating a Static Cache -A common pattern is a static `Dictionary` that caches expensive lookups across task invocations within a build: - **Before — static cache that leaks across builds:** ```csharp From fdaa6bebf675f77248c2f3b71a6c9cc5ce4bbfc0 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:23:28 +0100 Subject: [PATCH 3/6] Address some of the comments --- .../specs/multithreading/thread-safe-tasks.md | 43 +++++++++++-------- src/Framework/IBuildEngine4.cs | 2 +- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index 968a81f5e9d..f37a557e9cc 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -142,37 +142,43 @@ public bool Execute(...) } ``` -## Managing Static State Across Builds +## Managing Static State in Tasks +Static state in tasks can cause two issues: +- **Concurrency**: Race conditions when multiple threads access shared static data +- **Lifetime**: Static data persisting unexpectedly across multiple builds -### Problem: Static State Leaks Across Builds +### Concurrency -MSBuild reuses worker nodes across builds by default. Any static field in a task that runs on a reused node retains its value from previous builds. Tasks that run exclusively on the main (scheduler) node were historically unaffected because the main process exited after each build. With MSBuild Server, the main node also persists, extending this problem to those tasks as well. In multithreaded builds, static fields introduce an additional risk: concurrent tasks sharing the same static field can cause race conditions. +In multithreaded builds, concurrent tasks sharing the same static field can cause race conditions unless the field is designed for concurrent access. Thread-safety of static fields is the task author's responsibility, same as in any multithreaded application. -Static fields in tasks can: +### Lifetime -- **Leak data across builds** — a static cache populated during one build remains populated for subsequent builds on reused nodes, even if project state has changed. -- **Cause thread-safety issues** — in multithreaded builds, concurrent tasks sharing a static field can race unless the field is designed for concurrent access. +Static fields persist across multiple builds, meaning data cached during one build remains available in subsequent builds on the same node, regardless of changes to project state. -Thread-safety of static fields is the task author's responsibility, same as in any multithreaded application. For the data leak problem, MSBuild provides `IBuildEngine4.RegisterTaskObject` — an API that lets tasks store objects with explicit, engine-managed lifetimes instead of relying on static fields. +By default, MSBuild reuses worker nodes between builds. Previously, tasks running on the main (scheduler) node avoided this issue because the main process terminated after each build. However, with MSBuild Server (enabled by default in multithreaded builds), the main node now also persists across builds, extending this behavior to all tasks. + +This persistence is not inherently problematic and is often intentional — for example, caching expensive computations to improve performance across projects and builds. Such caching is acceptable when task authors implement proper cache invalidation strategies. The concerns below apply specifically when cached data becomes stale or incorrect and needs clean up after each build. + +MSBuild provides `IBuildEngine4.RegisterTaskObject` to address the lifetime issue: an API that lets tasks store objects with explicit, engine-managed lifetimes instead of relying on static fields. ```csharp void IBuildEngine4.RegisterTaskObject(object key, object obj, RegisteredTaskObjectLifetime lifetime, bool allowEarlyCollection); object IBuildEngine4.GetRegisteredTaskObject(object key, RegisteredTaskObjectLifetime lifetime); -object IBuildEngine4.UnregisterTaskObject(object key, RegisteredTaskObjectLifetime lifetime); ``` -The engine stores registered objects. All three methods are thread-safe and may be called concurrently from multiple tasks. If multiple tasks attempt to register an object with the same key concurrently, only the first registration takes effect — subsequent calls are ignored. When an object's lifetime expires, MSBuild calls `IDisposable.Dispose` on it if it implements `IDisposable`, then removes it. +The engine stores registered objects. Both methods are thread-safe and may be called concurrently from multiple tasks. If multiple tasks attempt to register an object with the same key concurrently, only the first registration takes effect — subsequent calls are ignored. When an object's lifetime expires, MSBuild removes it from the registry so no new consumers can retrieve it, then calls `IDisposable.Dispose` on it if it implements `IDisposable`. `RegisteredTaskObjectLifetime` controls when objects are disposed: | Lifetime | Disposed When | Use Case | |----------|---------------|----------| -| `Build` | The build completes | Per-build caches and resources that must not leak across builds. | +| `Build` | The whole build invocation completes (not a single project) | Per-build caches and resources that must not leak across builds. | | `AppDomain` | The MSBuild process exits | Objects that are safe to share across builds. | -With MSBuild Server, `Build` lifetime objects are disposed between each build request, giving task authors the same isolation they previously got from process-level separation. +`Build` lifetime objects are disposed between each build request, so task authors who depended on the isolation they previously got from the entrypoint process lifetime likely prefer it. + -### Example: Migrating a Static Cache +#### Example: Migrating a Static Cache **Before — static cache that leaks across builds:** @@ -225,9 +231,12 @@ public class MyTask : Task The cache is now scoped to a single build and automatically discarded when the build completes. -### Cleanup-on-Dispose Pattern -When a static cache is used by utility classes or helper methods that do not have access to `IBuildEngine`, it cannot be replaced with a registered task object. Instead, the task may keep the static field and register a disposable wrapper that clears it when the build ends: +> **Important:** When multiple tasks share a static field, for example through a utility class, migrating to `RegisterTaskObject` requires that _all_ tasks using the same key are migrated together. If some tasks are migrated while others continue to run in a separate task host process, the migrated tasks will use the engine-managed object while the non-migrated tasks will still use the static field, resulting in inconsistent behavior. + +#### Cleanup-on-Dispose Pattern + +When the previous `RegisterTaskObject` approach cannot be used — for example, when utility classes or helper methods use static caches but lack access to `IBuildEngine` — the recommended alternative is to keep the static field and register a disposable wrapper that clears it when the build ends: ```csharp internal static class MyHelper @@ -270,16 +279,14 @@ public class MyTask : Task } ``` -The same pattern can be applieed to third-party libraries that maintain their own static state — task may register a cleanup wrapper that calls the library's cache-clearing API. - -`allowEarlyCollection` need to be set to `false` because early collection would trigger the cleanup mid-build. The static cache would then continue accumulating entries for the remainder of the build with no end-of-build cleanup. +The same pattern must be applied to third-party libraries that maintain their own static state — a task may register a cleanup wrapper that calls the library's cache-clearing API. ### Guidelines for Task Authors 1. **Set `allowEarlyCollection: true`** when the cached data can be safely recreated. This lets MSBuild reclaim memory under pressure. Use `false` only for objects that must survive the entire build (e.g., cleanup wrappers, long-lived connections). 2. **Use a stable, unique key.** A `const string` with the fully-qualified task name avoids collisions (e.g., `"MyNamespace.MyTask.Cache"`). 3. **Handle null returns.** `GetRegisteredTaskObject` returns null when no object is registered under the key, or when a previously registered object was disposed through early collection. -4. **Registered objects must be thread-safe** in multithreaded builds, since multiple task instances may retrieve and use the same object concurrently. +4. **Objects used by multiple task invocations must be thread-safe** in multithreaded builds, since multiple task instances may retrieve and use the same object concurrently. ## Appendix: Alternatives diff --git a/src/Framework/IBuildEngine4.cs b/src/Framework/IBuildEngine4.cs index 549fdb83f9e..b915e10a8e8 100644 --- a/src/Framework/IBuildEngine4.cs +++ b/src/Framework/IBuildEngine4.cs @@ -93,7 +93,7 @@ public interface IBuildEngine4 : IBuildEngine3 /// has been discarded through early collection. /// /// - /// This method is thread-safe and may be called concurrently from multiple tasks. + /// This method is thread-safe and may be called concurrently from multiple tasks. However, another task may be using the object after this method returns. /// object UnregisterTaskObject(object key, RegisteredTaskObjectLifetime lifetime); } From d219087697a1f0e60c3a1ae8dd0097454acfbd9c Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:10:43 +0100 Subject: [PATCH 4/6] Add double check with locking pattern as an example --- .../specs/multithreading/thread-safe-tasks.md | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index f37a557e9cc..f748a629bb0 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -204,6 +204,7 @@ This cache persists across builds on reused nodes. It is also not thread-safe fo public class MyTask : Task { private const string CacheKey = "MyNamespace.MyTask.Cache"; + private static readonly object s_cacheLock = new(); public override bool Execute() { @@ -213,14 +214,19 @@ public class MyTask : Task CacheKey, RegisteredTaskObjectLifetime.Build); if (cache is null) { - engine4.RegisterTaskObject( - CacheKey, new ConcurrentDictionary(), - RegisteredTaskObjectLifetime.Build, - allowEarlyCollection: true); - // Re-read to get the authoritative instance in case another - // task registered first. - cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( - CacheKey, RegisteredTaskObjectLifetime.Build); + lock (s_cacheLock) + { + cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); + if (cache is null) + { + cache = new ConcurrentDictionary(); + engine4.RegisterTaskObject( + CacheKey, cache, + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: true); + } + } } cache[key] = value; @@ -231,6 +237,24 @@ public class MyTask : Task The cache is now scoped to a single build and automatically discarded when the build completes. +Alternatively, a **lock-free** version of the same pattern takes advantage of the fact that `RegisterTaskObject` is thread-safe and only keeps the first registration for a given key — subsequent calls are ignored. After registering, re-read with `GetRegisteredTaskObject` to obtain the authoritative instance: + +```csharp +var cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); +if (cache is null) +{ + engine4.RegisterTaskObject( + CacheKey, new ConcurrentDictionary(), + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: true); + // Re-read to get the authoritative instance in case another + // task registered first. + cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); +} +``` + > **Important:** When multiple tasks share a static field, for example through a utility class, migrating to `RegisterTaskObject` requires that _all_ tasks using the same key are migrated together. If some tasks are migrated while others continue to run in a separate task host process, the migrated tasks will use the engine-managed object while the non-migrated tasks will still use the static field, resulting in inconsistent behavior. From 8231cfd48e78b1671ba732a0ae324a86d7ff4eef Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:38:21 +0100 Subject: [PATCH 5/6] Update migration skill --- .../multithreaded-task-migration/SKILL.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/skills/multithreaded-task-migration/SKILL.md b/.github/skills/multithreaded-task-migration/SKILL.md index 3f97e8adf8f..f74ddd624cf 100644 --- a/.github/skills/multithreaded-task-migration/SKILL.md +++ b/.github/skills/multithreaded-task-migration/SKILL.md @@ -86,6 +86,104 @@ var psi = TaskEnvironment.GetProcessStartInfo(); psi.FileName = "tool.exe"; ``` +### Step 5: Ensure Static Fields Are Safe + +Review every static field for two independent issues: + +#### 5a. Concurrency — make static fields thread-safe + +```csharp +// BEFORE (UNSAFE) +private static readonly Dictionary s_cache = new(); + +// AFTER (SAFE) +private static readonly ConcurrentDictionary s_cache = new(); +``` + +For fields that don't fit a concurrent collection, use a `lock` or `Interlocked` APIs. + +#### 5b. Lifetime — scope per-build caches with `RegisterTaskObject` + +Not all caches need this. Caches valid across builds (e.g., expensive computations with stable inputs) should stay as static fields. Only migrate when data becomes stale between builds. + +**Replacing a static field with a registered task object:** + +```csharp +// BEFORE - leaks across builds (UNSAFE) +private static readonly Dictionary s_cache = new(); + +// AFTER - engine-managed lifetime (SAFE) +private const string CacheKey = "MyNamespace.MyTask.Cache"; +private static readonly object s_cacheLock = new(); + +public override bool Execute() +{ + var engine4 = (IBuildEngine4)BuildEngine; + + var cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); + if (cache is null) + { + lock (s_cacheLock) + { + cache = (ConcurrentDictionary)engine4.GetRegisteredTaskObject( + CacheKey, RegisteredTaskObjectLifetime.Build); + if (cache is null) + { + cache = new ConcurrentDictionary(); + engine4.RegisterTaskObject( + CacheKey, cache, + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: true); + } + } + } + + cache[key] = value; + ... +} +``` + +**Cleanup-on-Dispose pattern** — for static caches in utility classes without `IBuildEngine` access, register a disposable wrapper: + +```csharp +internal static class MyHelper +{ + private static readonly ConcurrentDictionary s_cache = new(); + internal static void ClearCache() => s_cache.Clear(); +} + +public class MyTask : Task +{ + private const string CleanerKey = "MyNamespace.MyTask.CacheCleaner"; + + public override bool Execute() + { + var engine4 = (IBuildEngine4)BuildEngine; + if (engine4.GetRegisteredTaskObject(CleanerKey, RegisteredTaskObjectLifetime.Build) is null) + { + engine4.RegisterTaskObject( + CleanerKey, + new CacheCleanup(MyHelper.ClearCache), + RegisteredTaskObjectLifetime.Build, + allowEarlyCollection: false); + } + ... + } + + private sealed class CacheCleanup(Action onDispose) : IDisposable + { + public void Dispose() => onDispose(); + } +} +``` + +**`RegisterTaskObject` guidelines:** +- `RegisteredTaskObjectLifetime.Build` for per-build data; `AppDomain` for cross-build data. +- `allowEarlyCollection: true` when data can be recreated; `false` for cleanup wrappers. +- Use a unique `const string` key (e.g., `"MyNamespace.MyTask.Cache"`). +- `GetRegisteredTaskObject` returns null when no object is registered or it was early-collected. + ## Updating Unit Tests **Every test creating a task instance must set TaskEnvironment.** Use `TaskEnvironmentHelper.CreateForTest()`: @@ -235,6 +333,8 @@ If your task spawns multiple threads internally, you must synchronize access to - [ ] All tests set `TaskEnvironment = TaskEnvironmentHelper.CreateForTest()` - [ ] Tests verify exception behavior for null/empty paths - [ ] No use of forbidden APIs (Environment.Exit, etc.) +- [ ] Static fields are thread-safe for concurrent access (concurrent collections, `Interlocked`, or `lock`) +- [ ] Per-build caches migrated to `RegisterTaskObject` with `Build` lifetime (or cleanup wrapper registered); cross-build caches can be left as static fields ## References From 116bdb66ab99a9300e72153a7700c791ca1393ab Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:47:13 +0100 Subject: [PATCH 6/6] Update documentation/specs/multithreading/thread-safe-tasks.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- documentation/specs/multithreading/thread-safe-tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index f748a629bb0..a4a662daa32 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -166,7 +166,7 @@ void IBuildEngine4.RegisterTaskObject(object key, object obj, RegisteredTaskObje object IBuildEngine4.GetRegisteredTaskObject(object key, RegisteredTaskObjectLifetime lifetime); ``` -The engine stores registered objects. Both methods are thread-safe and may be called concurrently from multiple tasks. If multiple tasks attempt to register an object with the same key concurrently, only the first registration takes effect — subsequent calls are ignored. When an object's lifetime expires, MSBuild removes it from the registry so no new consumers can retrieve it, then calls `IDisposable.Dispose` on it if it implements `IDisposable`. +The engine stores registered objects. Both methods are thread-safe and may be called concurrently from multiple tasks. If multiple tasks attempt to register an object with the same key concurrently, only the first registration takes effect — subsequent calls are ignored. When an object's lifetime expires, MSBuild disposes registered objects (calling `IDisposable.Dispose` on them if they implement `IDisposable`) and clears them from the registry, so their availability and cleanup are managed by the engine according to the configured lifetime. `RegisteredTaskObjectLifetime` controls when objects are disposed: