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(); + } +}