From 2736a5d9ed961af8b5107d789b008d03832d14fd Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 22:01:23 +1200 Subject: [PATCH 1/2] Introduce the per-run BuildContext foundation (FT-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds BuildContext — an internal, per-run, AsyncLocal-ambient scope activated at the top of BuildManager.Execute (`using var context = BuildContext.Activate()`) and disposed at method exit. It owns the per-run global-state lifecycle: the Console.CancelKeyPress / ToolOptions.Created subscriptions, the cancellation-handler list, and the teardown FT-1 centralised (now run on dispose). BuildManager.CancellationHandler becomes a thin facade over BuildContext.Current — the first "static surface over the context" seam, and the rail subsequent steps ride (FT-4 ParameterService, FT-5 tool-path config, FT-6 logging scope move their state onto the context). Internal-only; not a public contract until the SDK (milestone #7). Disposing on method exit means a failed `new T()` also leaks nothing. Full build + test suite green. --- src/Fallout.Build/Execution/BuildContext.cs | 61 +++++++++++++++++++++ src/Fallout.Build/Execution/BuildManager.cs | 32 +++-------- 2 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 src/Fallout.Build/Execution/BuildContext.cs diff --git a/src/Fallout.Build/Execution/BuildContext.cs b/src/Fallout.Build/Execution/BuildContext.cs new file mode 100644 index 000000000..baa5b5971 --- /dev/null +++ b/src/Fallout.Build/Execution/BuildContext.cs @@ -0,0 +1,61 @@ +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 runs on separate flows 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 current = new(); + + /// The context for the current build run, or null outside a run. + public static BuildContext Current => current.Value; + + private readonly LinkedList cancellationHandlers = new(); + private readonly ConsoleCancelEventHandler onCancelKeyPress; + private readonly EventHandler onToolOptionsCreated; + + 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() => 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; + Logging.InMemorySink.Instance.Clear(); + ValueInjectionUtility.ClearCache(); + NuGetToolPathResolver.Reset(); + NpmToolPathResolver.Reset(); + + if (ReferenceEquals(current.Value, this)) + current.Value = null; + } +} diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index eff09ba6c..33fe49dc3 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -8,7 +8,6 @@ using Fallout.Common.Tooling; using Fallout.Common.Utilities; using Fallout.Common.Utilities.Collections; -using Fallout.Common.ValueInjection; using Serilog; #pragma warning disable CA2255 @@ -18,12 +17,12 @@ internal static class BuildManager { private const int ErrorExitCode = -1; - private static readonly LinkedList 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 => cancellationHandlers.AddFirst(value); - remove => cancellationHandlers.Remove(value); + add => BuildContext.Current?.RegisterCancellationHandler(value); + remove => BuildContext.Current?.UnregisterCancellationHandler(value); } [ModuleInitializer] @@ -40,16 +39,11 @@ public static int Execute(Expression>[] defaultTargetExpressi Console.OutputEncoding = Encoding.UTF8; Console.InputEncoding = Encoding.UTF8; + // 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(); - // Hold the per-run global subscriptions in locals so the finally can undo exactly them — - // otherwise each Execute in the same process (tests, hosted scenarios) accumulates handlers. - // FT-1 / #306. - ConsoleCancelEventHandler onCancelKeyPress = (_, _) => cancellationHandlers.ForEach(x => x()); - EventHandler onToolOptionsCreated = (options, _) => VerbosityMapping.Apply((ToolOptions)options); - Console.CancelKeyPress += onCancelKeyPress; - ToolOptions.Created += onToolOptionsCreated; - try { Logging.Configure(build); @@ -95,16 +89,8 @@ public static int Execute(Expression>[] defaultTargetExpressi { Finish(); Log.CloseAndFlush(); - - // FT-1 (#306): undo this run's process-global state so a subsequent Execute in the same - // process starts clean — no accumulated handlers, no carried-over log events / caches / config. - CancellationHandler -= Finish; - Console.CancelKeyPress -= onCancelKeyPress; - ToolOptions.Created -= onToolOptionsCreated; - Logging.InMemorySink.Instance.Clear(); - ValueInjectionUtility.ClearCache(); - NuGetToolPathResolver.Reset(); - NpmToolPathResolver.Reset(); + // Per-run teardown (handler unsubscription + state reset) is owned by the BuildContext, + // run when `context` is disposed at method exit. } void Finish() From 646d23334430b6401cdc4595e98120f66391f79b Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 12 Jul 2026 21:50:10 +1200 Subject: [PATCH 2/2] Cover the BuildContext scope with specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the FT-2 ambient scope: Current lifecycle (null → activate → dispose), the identity guard that keeps a superseded context from clobbering a newer Current, the cancellation-handler facade no-op outside a run, and that Dispose runs the per-run teardown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Fallout.Build.Specs/BuildContextSpecs.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/Fallout.Build.Specs/BuildContextSpecs.cs diff --git a/tests/Fallout.Build.Specs/BuildContextSpecs.cs b/tests/Fallout.Build.Specs/BuildContextSpecs.cs new file mode 100644 index 000000000..37dd03b20 --- /dev/null +++ b/tests/Fallout.Build.Specs/BuildContextSpecs.cs @@ -0,0 +1,90 @@ +using System; +using FluentAssertions; +using Fallout.Common.Execution; +using Serilog.Events; +using Serilog.Parsing; +using Xunit; + +namespace Fallout.Common.Specs.Execution; + +/// +/// Covers (FT-2 / #307) — the per-run ambient scope that owns the +/// process-global state a build touches. is AsyncLocal, so +/// these cases stay isolated from any build running on another flow. +/// +public class BuildContextSpecs +{ + [Fact] + public void Current_is_null_outside_a_run() + { + BuildContext.Current.Should().BeNull(); + } + + [Fact] + public void Activate_installs_the_returned_context_as_current() + { + using var context = BuildContext.Activate(); + + BuildContext.Current.Should().BeSameAs(context); + } + + [Fact] + public void Dispose_clears_current() + { + var context = BuildContext.Activate(); + context.Dispose(); + + BuildContext.Current.Should().BeNull(); + } + + [Fact] + public void Disposing_a_superseded_context_leaves_the_newer_one_current() + { + var first = BuildContext.Activate(); + using var second = BuildContext.Activate(); + + // Disposing the older scope must not clobber the newer Current — the guard keys on identity. + first.Dispose(); + + BuildContext.Current.Should().BeSameAs(second); + } + + [Fact] + public void Cancellation_handler_facade_is_a_no_op_without_an_active_context() + { + Action noop = () => { }; + + // Register/unregister route through Current; outside a run there is nothing to route to, so + // the facade must swallow the call rather than throw. + var subscribe = () => + { + BuildManager.CancellationHandler += noop; + BuildManager.CancellationHandler -= noop; + }; + + subscribe.Should().NotThrow(); + } + + [Fact] + public void Dispose_runs_the_per_run_teardown() + { + var sink = Logging.InMemorySink.Instance; + var sentinel = $"build-context-teardown-{Guid.NewGuid()}"; + + using (BuildContext.Activate()) + sink.Emit(CreateLogEvent(sentinel)); + + // Leaving the scope disposes the context, whose teardown clears the shared in-memory sink so a + // subsequent run in the same process starts clean. Assert on the sentinel specifically so a + // build emitting on another flow can't make this flaky. + sink.LogEvents.Should().NotContain(x => x.MessageTemplate.Text == sentinel); + } + + private static LogEvent CreateLogEvent(string message) => + new( + timestamp: DateTimeOffset.UnixEpoch, + level: LogEventLevel.Warning, + exception: null, + messageTemplate: new MessageTemplateParser().Parse(message), + properties: Array.Empty()); +}