diff --git a/src/Fallout.Build/Execution/BuildContext.cs b/src/Fallout.Build/Execution/BuildContext.cs
new file mode 100644
index 00000000..34196f6f
--- /dev/null
+++ b/src/Fallout.Build/Execution/BuildContext.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Fallout.Common.Tooling;
+using Fallout.Common.Utilities.Collections;
+using Fallout.Common.ValueInjection;
+
+namespace Fallout.Common.Execution;
+
+///
+/// Per-run, process-ambient build state. Activated once at the top of
+/// and disposed at the end of the run, so the process-global statics a build touches are owned by a
+/// scope rather than leaking across invocations (the cleanup FT-1 centralised now lives here).
+/// The static surface (e.g. ) reads through
+/// ; AsyncLocal keeps concurrent/nested runs isolated.
+///
+///
+/// FT-2 / #307. Intentionally
+/// internal — not a public contract until the SDK lands (milestone #7). Subsequent steps move
+/// the per-run services (parameters, logging scope, tool-path config) onto this context.
+///
+internal sealed class BuildContext : IDisposable
+{
+ private static readonly AsyncLocal s_current = new();
+
+ /// The context for the current build run, or null outside a run.
+ public static BuildContext Current => s_current.Value;
+
+ private readonly LinkedList _cancellationHandlers = new();
+ private readonly ConsoleCancelEventHandler _onCancelKeyPress;
+ private readonly EventHandler _onToolOptionsCreated;
+
+ /// The per-run parameter service (FT-4 / #309) — replaces the process-global
+ /// ParameterService.Instance, so prod and tests exercise the same instance form.
+ public ParameterService Parameters { get; } =
+ new(() => EnvironmentInfo.ArgumentParser, () => EnvironmentInfo.Variables);
+
+ /// The per-run in-memory log sink (FT-6 / #311) — Serilog writes to it during the run and
+ /// WriteErrorsAndWarnings reads it; per-run scope keeps log events from carrying across runs.
+ public Logging.InMemorySink LogSink { get; } = new();
+
+ private BuildContext()
+ {
+ _onCancelKeyPress = (_, _) => _cancellationHandlers.ForEach(x => x());
+ _onToolOptionsCreated = (options, _) => VerbosityMapping.Apply((ToolOptions)options);
+ Console.CancelKeyPress += _onCancelKeyPress;
+ ToolOptions.Created += _onToolOptionsCreated;
+ }
+
+ /// Creates the ambient context for a build run and installs it as .
+ public static BuildContext Activate() => s_current.Value = new BuildContext();
+
+ public void RegisterCancellationHandler(Action handler) => _cancellationHandlers.AddFirst(handler);
+
+ public void UnregisterCancellationHandler(Action handler) => _cancellationHandlers.Remove(handler);
+
+ public void Dispose()
+ {
+ // Undo this run's process-global state (the FT-1 cleanup, now owned by the scope).
+ Console.CancelKeyPress -= _onCancelKeyPress;
+ ToolOptions.Created -= _onToolOptionsCreated;
+ // The per-run LogSink (FT-6) and Parameters (FT-4) are owned by this context and discarded
+ // with it — no explicit reset needed. These remaining statics aren't yet context-owned:
+ ValueInjectionUtility.ClearCache();
+ NuGetToolPathResolver.Reset();
+ NpmToolPathResolver.Reset();
+
+ if (ReferenceEquals(s_current.Value, this))
+ s_current.Value = null;
+ }
+}
diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs
index 5925fcec..33fe49dc 100644
--- a/src/Fallout.Build/Execution/BuildManager.cs
+++ b/src/Fallout.Build/Execution/BuildManager.cs
@@ -17,12 +17,12 @@ internal static class BuildManager
{
private const int ErrorExitCode = -1;
- private static readonly LinkedList s_cancellationHandlers = new();
-
+ // Facade over the active BuildContext (FT-2): callers register/unregister during a run; the
+ // context owns the handler list and discards it on dispose.
public static event Action CancellationHandler
{
- add => s_cancellationHandlers.AddFirst(value);
- remove => s_cancellationHandlers.Remove(value);
+ add => BuildContext.Current?.RegisterCancellationHandler(value);
+ remove => BuildContext.Current?.UnregisterCancellationHandler(value);
}
[ModuleInitializer]
@@ -38,9 +38,10 @@ public static int Execute(Expression>[] defaultTargetExpressi
{
Console.OutputEncoding = Encoding.UTF8;
Console.InputEncoding = Encoding.UTF8;
- Console.CancelKeyPress += (_, _) => s_cancellationHandlers.ForEach(x => x());
- ToolOptions.Created += (options, _) => VerbosityMapping.Apply((ToolOptions)options);
+ // The per-run scope owns the global subscriptions + teardown (FT-2 / #307). Disposed at
+ // method exit, so re-invocation in the same process starts clean even if `new T()` throws.
+ using var context = BuildContext.Activate();
var build = new T();
try
@@ -88,6 +89,8 @@ public static int Execute(Expression>[] defaultTargetExpressi
{
Finish();
Log.CloseAndFlush();
+ // Per-run teardown (handler unsubscription + state reset) is owned by the BuildContext,
+ // run when `context` is disposed at method exit.
}
void Finish()
diff --git a/src/Fallout.Build/Execution/ParameterService.Statics.cs b/src/Fallout.Build/Execution/ParameterService.Statics.cs
index e8e026b1..0eec065b 100644
--- a/src/Fallout.Build/Execution/ParameterService.Statics.cs
+++ b/src/Fallout.Build/Execution/ParameterService.Statics.cs
@@ -2,15 +2,20 @@
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
+using Fallout.Common.Execution;
using Fallout.Common.Utilities;
namespace Fallout.Common;
internal partial class ParameterService
{
- internal static ParameterService Instance = new(
- () => EnvironmentInfo.ArgumentParser,
- () => EnvironmentInfo.Variables);
+ // FT-4 (#309): the active instance is the per-run one held on BuildContext, so prod uses the
+ // same instance form as tests and nothing leaks across runs. The lazy fallback covers the rare
+ // access outside a build run (no cross-run concern there); it can retire once that's confirmed dead.
+ private static readonly Lazy s_ambient = new(
+ () => new ParameterService(() => EnvironmentInfo.ArgumentParser, () => EnvironmentInfo.Variables));
+
+ internal static ParameterService Instance => BuildContext.Current?.Parameters ?? s_ambient.Value;
public static T GetParameter(string name, char? separator = null)
{
diff --git a/src/Fallout.Build/Execution/ValueInjectionUtility.cs b/src/Fallout.Build/Execution/ValueInjectionUtility.cs
index 8d637a3e..06e42452 100644
--- a/src/Fallout.Build/Execution/ValueInjectionUtility.cs
+++ b/src/Fallout.Build/Execution/ValueInjectionUtility.cs
@@ -12,6 +12,9 @@ internal static class ValueInjectionUtility
{
private static readonly Dictionary s_valueCache = new();
+ /// Clears the per-run injected-value cache so a subsequent build in the same process re-injects. FT-1 / #306.
+ internal static void ClearCache() => s_valueCache.Clear();
+
public static T TryGetValue(Expression> parameterExpression)
where T : class
{
diff --git a/src/Fallout.Build/Logging.cs b/src/Fallout.Build/Logging.cs
index 9d99f505..91d991a1 100644
--- a/src/Fallout.Build/Logging.cs
+++ b/src/Fallout.Build/Logging.cs
@@ -211,11 +211,15 @@ public static IDisposable SetTarget(string name)
public class InMemorySink : ILogEventSink, IDisposable
{
- public static InMemorySink Instance { get; } = new();
+ // FT-6 (#311): the active sink is the per-run one held on BuildContext, so log events don't
+ // carry across invocations. The lazy fallback covers logging outside a build run.
+ private static readonly Lazy s_fallback = new(() => new InMemorySink());
+
+ public static InMemorySink Instance => BuildContext.Current?.LogSink ?? s_fallback.Value;
private readonly List _logEvents;
- private InMemorySink()
+ internal InMemorySink()
{
_logEvents = new List();
}
@@ -228,10 +232,16 @@ public void Emit(LogEvent logEvent)
_logEvents.Add(logEvent);
}
- public void Dispose()
+ /// Drops accumulated events so a subsequent build in the same process starts clean. FT-1 / #306.
+ public void Clear()
{
_logEvents.Clear();
}
+
+ public void Dispose()
+ {
+ Clear();
+ }
}
internal class ExecutingTargetLogEventEnricher : ILogEventEnricher
diff --git a/src/Fallout.Tooling/NpmToolPathResolver.cs b/src/Fallout.Tooling/NpmToolPathResolver.cs
index d94f91d6..1ec405e2 100644
--- a/src/Fallout.Tooling/NpmToolPathResolver.cs
+++ b/src/Fallout.Tooling/NpmToolPathResolver.cs
@@ -8,6 +8,12 @@ public static class NpmToolPathResolver
{
public static AbsolutePath NpmPackageJsonFile;
+ /// Resets the per-run package.json location so a subsequent build in the same process starts from defaults. FT-1 / #306.
+ public static void Reset()
+ {
+ NpmPackageJsonFile = null;
+ }
+
public static string GetNpmExecutable(string npmExecutable)
{
Assert.FileExists(NpmPackageJsonFile);
diff --git a/src/Fallout.Tooling/NuGetToolPathResolver.cs b/src/Fallout.Tooling/NuGetToolPathResolver.cs
index 78e33548..6df96e92 100644
--- a/src/Fallout.Tooling/NuGetToolPathResolver.cs
+++ b/src/Fallout.Tooling/NuGetToolPathResolver.cs
@@ -16,6 +16,15 @@ public static class NuGetToolPathResolver
public static string NuGetAssetsConfigFile;
public static string PaketPackagesConfigFile;
+ /// Resets the per-run package-location config so a subsequent build in the same process starts from defaults. FT-1 / #306.
+ public static void Reset()
+ {
+ EmbeddedPackagesDirectory = null;
+ NuGetPackagesConfigFile = null;
+ NuGetAssetsConfigFile = null;
+ PaketPackagesConfigFile = null;
+ }
+
public static string GetPackageExecutable(string packageId, string packageExecutable, string version = null, string framework = null)
{
Assert.True(packageId != null && packageExecutable != null);
diff --git a/tests/Fallout.Build.Specs/BuildContextSpecs.cs b/tests/Fallout.Build.Specs/BuildContextSpecs.cs
new file mode 100644
index 00000000..b501dffb
--- /dev/null
+++ b/tests/Fallout.Build.Specs/BuildContextSpecs.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using Fallout.Common.Execution;
+using Xunit;
+
+namespace Fallout.Common.Specs;
+
+///
+/// Isolation harness for the per-run (FT-9 / #314). Asserts the
+/// guarantees FT-1/2/4/6 introduced: a build run is scoped, and re-invoking in the same process
+/// starts from fresh per-run state with no carry-over. (A full reentrant BuildManager.Execute
+/// run needs a Console-driven build harness and is a separate addition.)
+///
+public class BuildContextSpecs
+{
+ [Fact]
+ public void Activate_installs_the_context_as_Current()
+ {
+ using var context = BuildContext.Activate();
+ BuildContext.Current.Should().BeSameAs(context);
+ }
+
+ [Fact]
+ public void Dispose_clears_Current()
+ {
+ var context = BuildContext.Activate();
+ BuildContext.Current.Should().NotBeNull();
+
+ context.Dispose();
+
+ BuildContext.Current.Should().BeNull();
+ }
+
+ [Fact]
+ public void Each_run_gets_fresh_per_run_state()
+ {
+ ParameterService firstParameters;
+ Logging.InMemorySink firstLogSink;
+ using (var first = BuildContext.Activate())
+ {
+ firstParameters = first.Parameters;
+ firstLogSink = first.LogSink;
+ }
+
+ using var second = BuildContext.Activate();
+ second.Parameters.Should().NotBeSameAs(firstParameters);
+ second.LogSink.Should().NotBeSameAs(firstLogSink);
+ }
+
+ [Fact]
+ public void Static_facades_route_to_the_active_context()
+ {
+ using var context = BuildContext.Activate();
+ ParameterService.Instance.Should().BeSameAs(context.Parameters);
+ Logging.InMemorySink.Instance.Should().BeSameAs(context.LogSink);
+ }
+
+ [Fact]
+ public void Static_facades_fall_back_outside_a_run()
+ {
+ // After any run has been disposed, the facades must still resolve (the lazy fallback) rather
+ // than throw — and they no longer point at a disposed run's state.
+ using (BuildContext.Activate())
+ {
+ }
+
+ BuildContext.Current.Should().BeNull();
+ ParameterService.Instance.Should().NotBeNull();
+ Logging.InMemorySink.Instance.Should().NotBeNull();
+ }
+}