Skip to content
2 changes: 1 addition & 1 deletion src/TUnit.Core/Services/ModeDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ namespace TUnit.Core.Services;
/// </summary>
public static class ModeDetector
{
public static bool IsSourceGenerationAvailable => SourceRegistrar.IsEnabled;
public static bool IsSourceGenerationAvailable => SourceRegistrar.IsSourceGenerationAvailable;
}
74 changes: 73 additions & 1 deletion src/TUnit.Core/SourceRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,79 @@ namespace TUnit.Core;
/// </summary>
public class SourceRegistrar
{
public static bool IsEnabled { get; set; }
private static readonly Lock ModeLock = new();
private static volatile bool _isEnabled;
private static volatile bool _isSourceGenerationAvailable;
private static bool _isModeLocked;

/// <summary>
/// Gets whether the source-generated execution mode is in use.
/// Generated module initializers set this to <c>true</c>. Once the engine has chosen the
/// execution mode, later writes do not change it, so a module initializer from an assembly that
/// loads mid-run cannot switch a reflection-mode run back to source-generated mode. Every write
/// still updates <see cref="IsSourceGenerationAvailable"/>.
/// </summary>
/// <remarks>
/// The engine owns this value after startup. Setting it once the engine has selected the
/// execution mode has no effect. A write that would have changed the value is reported
/// through <see cref="Trace.WriteLine(string)"/>. To choose the mode, use the
/// <c>--reflection</c> or <c>--tunit-execution-mode</c> command-line options, the
/// <c>TUNIT_EXECUTION_MODE</c> environment variable, or <see cref="ReflectionModeAttribute"/>.
/// </remarks>
public static bool IsEnabled
{
get => _isEnabled;
set
{
lock (ModeLock)
{
_isSourceGenerationAvailable = value;

if (!_isModeLocked)
{
_isEnabled = value;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}

if (_isEnabled == value)
{
return;
}
}

ReportIgnoredWrite(value);
}
}

/// <summary>
/// Gets whether generated module initializers registered source-generated tests, independent of
/// the execution mode the engine selected. Writes to <see cref="IsEnabled"/> update this value
/// even after the mode is locked, so a later engine instance in the same process that has no
/// explicit mode option still falls back to source-generated mode when it is available.
/// </summary>
internal static bool IsSourceGenerationAvailable => _isSourceGenerationAvailable;

/// <summary>
/// Sets the execution mode the engine selected and stops later <see cref="IsEnabled"/> writes
/// from changing it. A later call, such as from another engine instance in the same process,
/// replaces the locked value, so the most recent engine run decides the mode.
/// </summary>
internal static void LockExecutionMode(bool useSourceGeneration)
{
lock (ModeLock)
{
_isEnabled = useSourceGeneration;
_isModeLocked = true;
}
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void ReportIgnoredWrite(bool value)
{
Trace.WriteLine(value
? "[TUnit.Core] Ignored SourceRegistrar.IsEnabled = true: the engine already selected reflection mode. A generated module initializer from an assembly that loaded after startup usually causes this."
: "[TUnit.Core] Ignored SourceRegistrar.IsEnabled = false: the engine already selected source-generated mode. Use --reflection, --tunit-execution-mode, TUNIT_EXECUTION_MODE or [assembly: ReflectionMode] to choose the mode.");
}

/// <summary>
/// Registers an assembly loader.
Expand Down
4 changes: 3 additions & 1 deletion src/TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ public TUnitServiceProvider(IExtension extension,
var logLevelProvider = Register(new LogLevelProvider(CommandLineOptions));

// Determine execution mode early to create appropriate services
var useSourceGeneration = SourceRegistrar.IsEnabled = ExecutionModeHelper.IsSourceGenerationMode(CommandLineOptions);
// Lock the mode so module initializers of assemblies that load later cannot flip it back.
var useSourceGeneration = ExecutionModeHelper.IsSourceGenerationMode(CommandLineOptions);
SourceRegistrar.LockExecutionMode(useSourceGeneration);

// Create and register mode-specific hook registrar service
IHookRegistrar hookDiscoveryService;
Expand Down
4 changes: 2 additions & 2 deletions src/TUnit.Engine/Helpers/ExecutionModeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal static class ExecutionModeHelper
/// 3. Command line --tunit-execution-mode
/// 4. Assembly-level [ReflectionMode] attribute
/// 5. Environment variable TUNIT_EXECUTION_MODE
/// 6. Default (SourceRegistrar.IsEnabled)
/// 6. Default (SourceRegistrar.IsSourceGenerationAvailable)
/// </remarks>
public static bool IsSourceGenerationMode(ICommandLineOptions commandLineOptions)
{
Expand Down Expand Up @@ -82,6 +82,6 @@ public static bool IsSourceGenerationMode(ICommandLineOptions commandLineOptions
}
}

return SourceRegistrar.IsEnabled;
return SourceRegistrar.IsSourceGenerationAvailable;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new
global::TUnit.Core.TestEntryFactory.Create<global::TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests>(
methodName: "SameType<int>",
fullyQualifiedName: "TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests.SameType<int>",
methodTypeArgumentsIdentity: "<int>",
filePath: "",
lineNumber: 12,
hasDataSource: true,
Expand Down Expand Up @@ -164,6 +165,7 @@ global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new
global::TUnit.Core.TestEntryFactory.Create<global::TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests>(
methodName: "MixedTypes<int>",
fullyQualifiedName: "TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests.MixedTypes<int>",
methodTypeArgumentsIdentity: "<int>",
filePath: "",
lineNumber: 21,
hasDataSource: true,
Expand All @@ -181,6 +183,7 @@ global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new
global::TUnit.Core.TestEntryFactory.Create<global::TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests>(
methodName: "MixedTypes<string>",
fullyQualifiedName: "TUnit.TestProject.Bugs._7096.GenericMethodSameTypeArgumentsTests.MixedTypes<string>",
methodTypeArgumentsIdentity: "<string>",
filePath: "",
lineNumber: 21,
hasDataSource: true,
Expand Down Expand Up @@ -258,6 +261,7 @@ global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new
global::TUnit.Core.TestEntryFactory.Create<global::TUnit.TestProject.Bugs._7096.GenericMethodSameTypeRowSettingsTests>(
methodName: "RowSettings<int>",
fullyQualifiedName: "TUnit.TestProject.Bugs._7096.GenericMethodSameTypeRowSettingsTests.RowSettings<int>",
methodTypeArgumentsIdentity: "<int>",
filePath: "",
lineNumber: 39,
hasDataSource: true,
Expand Down
20 changes: 20 additions & 0 deletions tests/TUnit.Engine.Tests/Issue7089Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

public class Issue7089Tests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Late_IsEnabled_Write_Does_Not_Change_Selected_Mode()
{
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._7089/ExecutionModeLockTests/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0),
]);
}
}
42 changes: 42 additions & 0 deletions tests/TUnit.TestProject/Bugs/7089/ExecutionModeLockTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using TUnit.Core.Services;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7089;

/// <summary>
/// Regression test for #7089: the generated module initializer of an assembly that loads mid-run
/// writes <c>SourceRegistrar.IsEnabled = true</c>. That write must not change the execution mode
/// the engine already selected.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class ExecutionModeLockTests
{
[Test]
public async Task Late_IsEnabled_Write_Does_Not_Change_Selected_Mode()
{
// The engine has already selected and locked the mode by the time a test runs. Capture it
// rather than inferring it from the command line, which is only one way to select it.
var selectedSourceGeneration = SourceRegistrar.IsEnabled;
var sourceGenerationAvailable = ModeDetector.IsSourceGenerationAvailable;

try
{
// What a late-loading assembly's generated module initializer does. The write must not
// change the selected mode, but it must still record that source generation is
// available, so a later engine instance in the process can fall back to it.
SourceRegistrar.IsEnabled = true;
await Assert.That(SourceRegistrar.IsEnabled).IsEqualTo(selectedSourceGeneration);
await Assert.That(ModeDetector.IsSourceGenerationAvailable).IsTrue();

SourceRegistrar.IsEnabled = false;
await Assert.That(SourceRegistrar.IsEnabled).IsEqualTo(selectedSourceGeneration);
await Assert.That(ModeDetector.IsSourceGenerationAvailable).IsFalse();
}
finally
{
// Without the fix the writes above stick. Once the mode is locked this write only
// restores the availability signal; the selected mode stays as it is.
SourceRegistrar.IsEnabled = sourceGenerationAvailable;
}
}
}
2 changes: 1 addition & 1 deletion tests/TUnit.UnitTests/BannerCapabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task Falls_Back_To_Source_Registration_Without_Overrides()
// With no CLI or environment override, the engine falls back to whether generated sources
// registered themselves (false when EnableTUnitSourceGeneration=false strips the generator).
var options = new FakeCommandLineOptions();
var expected = SourceRegistrar.IsEnabled
var expected = SourceRegistrar.IsSourceGenerationAvailable
? EngineMode.SourceGenerated
: EngineMode.Reflection;

Expand Down
19 changes: 19 additions & 0 deletions tests/TUnit.UnitTests/SourceRegistrarExecutionModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace TUnit.UnitTests;

/// <summary>
/// Regression test for #7089. This project references TUnit.Playwright, which is built with the
/// source generator. In reflection mode its generated module initializer first runs during
/// discovery, after the engine has selected the mode, and used to set
/// <see cref="SourceRegistrar.IsEnabled"/> back to <c>true</c>. Closed generic test classes then
/// failed with "Cannot use reflection-based hook discovery when source generation is enabled".
/// Run this project with and without <c>--reflection</c> to cover both modes.
/// </summary>
[GenerateGenericTest(typeof(int))]
public class SourceRegistrarExecutionModeGenericTests<T>
{
[Test]
public async Task Closed_Generic_Test_Class_Runs()
{
await Assert.That(typeof(T)).IsEqualTo(typeof(int));
}
}
Loading