Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/Fallout.Build/Execution/BuildContext.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Per-run, process-ambient build state. Activated once at the top of <see cref="BuildManager.Execute{T}"/>
/// 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. <see cref="BuildManager.CancellationHandler"/>) reads through
/// <see cref="Current"/>; <c>AsyncLocal</c> keeps concurrent runs on separate flows isolated.
/// </summary>
Comment on lines +14 to +16
/// <remarks>
/// FT-2 / <see href="https://github.com/Fallout-build/Fallout/issues/307">#307</see>. Intentionally
/// <c>internal</c> — 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.
/// </remarks>
internal sealed class BuildContext : IDisposable
{
private static readonly AsyncLocal<BuildContext> current = new();

/// <summary>The context for the current build run, or <c>null</c> outside a run.</summary>
public static BuildContext Current => current.Value;

private readonly LinkedList<Action> 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;
}

/// <summary>Creates the ambient context for a build run and installs it as <see cref="Current"/>.</summary>
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;
}
}
Comment on lines +48 to +61
32 changes: 9 additions & 23 deletions src/Fallout.Build/Execution/BuildManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -18,12 +17,12 @@ internal static class BuildManager
{
private const int ErrorExitCode = -1;

private static readonly LinkedList<Action> 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]
Expand All @@ -40,16 +39,11 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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);
Expand Down Expand Up @@ -95,16 +89,8 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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()
Expand Down
90 changes: 90 additions & 0 deletions tests/Fallout.Build.Specs/BuildContextSpecs.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Covers <see cref="BuildContext"/> (FT-2 / #307) — the per-run ambient scope that owns the
/// process-global state a build touches. <see cref="BuildContext.Current"/> is <c>AsyncLocal</c>, so
/// these cases stay isolated from any build running on another flow.
/// </summary>
Comment on lines +11 to +14
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<LogEventProperty>());
}
Loading