Skip to content
Draft
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
71 changes: 71 additions & 0 deletions src/Fallout.Build/Execution/BuildContext.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <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/nested runs isolated.
/// </summary>
/// <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> s_current = new();

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

private readonly LinkedList<Action> _cancellationHandlers = new();
private readonly ConsoleCancelEventHandler _onCancelKeyPress;
private readonly EventHandler _onToolOptionsCreated;

/// <summary>The per-run parameter service (FT-4 / #309) — replaces the process-global
/// <c>ParameterService.Instance</c>, so prod and tests exercise the same instance form.</summary>
public ParameterService Parameters { get; } =
new(() => EnvironmentInfo.ArgumentParser, () => EnvironmentInfo.Variables);

/// <summary>The per-run in-memory log sink (FT-6 / #311) — Serilog writes to it during the run and
/// <c>WriteErrorsAndWarnings</c> reads it; per-run scope keeps log events from carrying across runs.</summary>
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;
}

/// <summary>Creates the ambient context for a build run and installs it as <see cref="Current"/>.</summary>
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;
}
}
15 changes: 9 additions & 6 deletions src/Fallout.Build/Execution/BuildManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ internal static class BuildManager
{
private const int ErrorExitCode = -1;

private static readonly LinkedList<Action> 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]
Expand All @@ -38,9 +38,10 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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
Expand Down Expand Up @@ -88,6 +89,8 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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()
Expand Down
11 changes: 8 additions & 3 deletions src/Fallout.Build/Execution/ParameterService.Statics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParameterService> s_ambient = new(
() => new ParameterService(() => EnvironmentInfo.ArgumentParser, () => EnvironmentInfo.Variables));

internal static ParameterService Instance => BuildContext.Current?.Parameters ?? s_ambient.Value;

public static T GetParameter<T>(string name, char? separator = null)
{
Expand Down
3 changes: 3 additions & 0 deletions src/Fallout.Build/Execution/ValueInjectionUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ internal static class ValueInjectionUtility
{
private static readonly Dictionary<MemberInfo, object> s_valueCache = new();

/// <summary>Clears the per-run injected-value cache so a subsequent build in the same process re-injects. FT-1 / #306.</summary>
internal static void ClearCache() => s_valueCache.Clear();

public static T TryGetValue<T>(Expression<Func<T>> parameterExpression)
where T : class
{
Expand Down
16 changes: 13 additions & 3 deletions src/Fallout.Build/Logging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InMemorySink> s_fallback = new(() => new InMemorySink());

public static InMemorySink Instance => BuildContext.Current?.LogSink ?? s_fallback.Value;

private readonly List<LogEvent> _logEvents;

private InMemorySink()
internal InMemorySink()
{
_logEvents = new List<LogEvent>();
}
Expand All @@ -228,10 +232,16 @@ public void Emit(LogEvent logEvent)
_logEvents.Add(logEvent);
}

public void Dispose()
/// <summary>Drops accumulated events so a subsequent build in the same process starts clean. FT-1 / #306.</summary>
public void Clear()
{
_logEvents.Clear();
}

public void Dispose()
{
Clear();
}
}

internal class ExecutingTargetLogEventEnricher : ILogEventEnricher
Expand Down
6 changes: 6 additions & 0 deletions src/Fallout.Tooling/NpmToolPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ public static class NpmToolPathResolver
{
public static AbsolutePath NpmPackageJsonFile;

/// <summary>Resets the per-run package.json location so a subsequent build in the same process starts from defaults. FT-1 / #306.</summary>
public static void Reset()
{
NpmPackageJsonFile = null;
}

public static string GetNpmExecutable(string npmExecutable)
{
Assert.FileExists(NpmPackageJsonFile);
Expand Down
9 changes: 9 additions & 0 deletions src/Fallout.Tooling/NuGetToolPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public static class NuGetToolPathResolver
public static string NuGetAssetsConfigFile;
public static string PaketPackagesConfigFile;

/// <summary>Resets the per-run package-location config so a subsequent build in the same process starts from defaults. FT-1 / #306.</summary>
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);
Expand Down
70 changes: 70 additions & 0 deletions tests/Fallout.Build.Specs/BuildContextSpecs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using FluentAssertions;
using Fallout.Common.Execution;
using Xunit;

namespace Fallout.Common.Specs;

/// <summary>
/// Isolation harness for the per-run <see cref="BuildContext"/> (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 <c>BuildManager.Execute</c>
/// run needs a Console-driven build harness and is a separate addition.)
/// </summary>
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();
}
}
Loading