Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -395,4 +395,7 @@ FodyWeavers.xsd
*.msp

# JetBrains Rider
*.sln.iml
*.sln.iml

# Claude Code
.claude/
18 changes: 9 additions & 9 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,25 @@
<ItemGroup>
<!-- Development Tools -->
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.102">
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.103">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="DotNext.Metaprogramming" Version="5.26.2" />
<PackageVersion Include="FastExpressionCompiler" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<PackageVersion Include="coverlet.collector" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.0.2" />
<PackageVersion Include="MSTest.TestFramework" Version="4.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.1.0" />
<PackageVersion Include="MSTest.TestFramework" Version="4.1.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
<!-- Roslyn: use consistent version for all frameworks -->
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
Expand Down
5 changes: 4 additions & 1 deletion src/Hyperbee.Expressions.Lab/Hyperbee.Expressions.Lab.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
<ProjectReference Include="..\Hyperbee.Expressions\Hyperbee.Expressions.csproj" />
<PackageReference Update="Microsoft.SourceLink.GitHub" />
<PackageReference Update="Microsoft.SourceLink.GitHub">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion src/Hyperbee.Expressions/AsyncBlockExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ private AsyncLoweringInfo LoweringTransformer()
{
try
{
var visitor = new AsyncLoweringVisitor();
var visitor = new AsyncLoweringVisitor { Optimize = RuntimeOptions?.Optimize ?? true };

return visitor.Transform(
Result.Type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,15 @@ internal static Expression Create<TResult>( AsyncLoweringTransformer loweringTra
var stateMachineBuilder = new AsyncStateMachineBuilder<TResult>( moduleBuilder, typeName );
var stateMachineExpression = stateMachineBuilder.CreateStateMachine( loweringTransformer, __id );

if ( options.SourceHandler != null )
{
var debugView = GetDebugView( stateMachineExpression );
options.SourceHandler( debugView );
}

return stateMachineExpression; // the-best expression breakpoint ever
}

[UnsafeAccessor( UnsafeAccessorKind.Method, Name = "get_DebugView" )]
private static extern string GetDebugView( Expression expression );
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ internal class AsyncLoweringVisitor : BaseLoweringVisitor<AsyncLoweringInfo>

private int _awaitCount;

public bool Optimize { get; init; } = true;

public override AsyncLoweringInfo Transform(
Type resultType,
ParameterExpression[] localVariables,
Expand All @@ -27,7 +29,8 @@ public override AsyncLoweringInfo Transform(

VisitExpressions( expressions );

StateOptimizer.Optimize( States );
if ( Optimize )
StateOptimizer.Optimize( States );

ThrowIfInvalid();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public override void AddExpressions( List<Expression> expressions, StateMachineC
if ( expressions.Count <= 1 || expressions[^1].Type == typeof( void ) )
{
value ??= Constant( null, typeof( IVoidResult ) );
expressions.Add( Assign( variable, value ) );
expressions.Add( Assign( variable, EnsureConvert( value, variable.Type ) ) );
return;
}

Expand All @@ -34,7 +34,7 @@ public override void AddExpressions( List<Expression> expressions, StateMachineC

if ( variable.Type.IsAssignableFrom( lastExpression.Type ) )
{
expressions[^1] = Assign( variable, lastExpression );
expressions[^1] = Assign( variable, EnsureConvert( lastExpression, variable.Type ) );
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,26 @@ private static void SetResult( List<Expression> expressions, StateMachineContext

if ( variable.Type.IsAssignableFrom( lastExpression.Type ) )
{
expressions[^1] = Assign( variable, lastExpression );
expressions[^1] = Assign( variable, EnsureConvert( lastExpression, variable.Type ) );
}

return;
}

if ( value != null && variable.Type.IsAssignableFrom( value.Type ) )
{
expressions.Add( Assign( variable, value ) );
expressions.Add( Assign( variable, EnsureConvert( value, variable.Type ) ) );
}
}

protected static Expression EnsureConvert( Expression expression, Type targetType )
{
if ( expression.Type != targetType && expression.Type.IsValueType )
return Convert( expression, targetType );

return expression;
}

internal abstract void Optimize( HashSet<LabelTarget> references );

protected static StateNode OptimizeGotos( StateNode node )
Expand Down
12 changes: 11 additions & 1 deletion src/Hyperbee.Expressions/CompilerServices/VariableResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ private static class VariableName
private const int InitialCapacity = 8;

private int _variableId;
private ParameterExpression _finalResultVariable;

private readonly StateContext _states;

Expand Down Expand Up @@ -100,7 +101,8 @@ public Expression GetExceptionVariable( int stateId )
[MethodImpl( MethodImplOptions.AggressiveInlining )]
public ParameterExpression GetFinalResult( Type type )
{
return AddVariable( Variable( type, VariableName.FinalResult ) );
_finalResultVariable = AddVariable( Variable( type, VariableName.FinalResult ) );
return _finalResultVariable;
}

// Resolver
Expand Down Expand Up @@ -150,6 +152,14 @@ protected override Expression VisitGoto( GotoExpression node )
if ( _labels.TryGetValue( node.Target, out var label ) )
return label;

// BUG FIX: Return gotos inside non-lowered expressions (e.g. IfThen without Await)
// must assign _finalResultVariable so the state machine result is set.
if ( _finalResultVariable != null && node.Kind == GotoExpressionKind.Return && node.Value != null )
{
var visitedValue = Visit( node.Value );
return node.Update( node.Target, Assign( _finalResultVariable, visitedValue ) );
}

return base.VisitGoto( node );
}

Expand Down
13 changes: 13 additions & 0 deletions src/Hyperbee.Expressions/ExpressionRuntimeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,17 @@ public class ExpressionRuntimeOptions
/// Defaults to <see cref="DefaultModuleBuilderProvider"/>
/// </summary>
public IModuleBuilderProvider ModuleBuilderProvider { get; init; } = DefaultModuleBuilderProvider.Instance;

/// <summary>
/// Gets or sets whether state machine optimizations are enabled.
/// When false, the goto optimizer is skipped, preserving the raw lowered state graph.
/// Defaults to true.
/// </summary>
public bool Optimize { get; init; } = true;

/// <summary>
/// Gets or sets an optional callback to receive the generated state machine source.
/// When set, the state machine expression debug view is passed as a string for inspection.
/// </summary>
public Action<string> SourceHandler { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" />
<PackageReference Update="Microsoft.SourceLink.GitHub">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,74 @@
Assert.AreEqual( 1, result1 );
Assert.AreEqual( 2, result2 );
// Provider should be called at least once per block (might be called during Reduce)
Assert.IsTrue( trackingProvider.AsyncCallCount >= 2,

Check warning on line 177 in test/Hyperbee.Expressions.Tests/AsyncBlockExpressionRuntimeOptionsTests.cs

View workflow job for this annotation

GitHub Actions / test / test (net10.0)

Use 'Assert.IsGreaterThanOrEqualTo' instead of 'Assert.IsTrue' (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0037)
$"Expected at least 2 calls, but got {trackingProvider.AsyncCallCount}" );
// Verify the same ModuleBuilder instance is reused
Assert.AreSame( trackingProvider.GetModuleBuilder( ModuleKind.Async ),
trackingProvider.GetModuleBuilder( ModuleKind.Async ) );
}

[TestMethod]
[DataRow( CompilerType.Fast )]
[DataRow( CompilerType.System )]
[DataRow( CompilerType.Interpret )]
public async Task BlockAsync_SourceHandler_ShouldCaptureStateMachineSource( CompilerType compiler )
{
// Arrange
string capturedSource = null;
var options = new ExpressionRuntimeOptions
{
SourceHandler = source => capturedSource = source
};

var block = BlockAsync(
new[]
{
Await( AsyncHelper.Completer(
Constant( CompleterType.Immediate ),
Constant( 42 )
) )
},
options
);

var lambda = Lambda<Func<Task<int>>>( block );
var compiledLambda = lambda.Compile( compiler );

// Act
var result = await compiledLambda();

// Assert
Assert.AreEqual( 42, result );
Assert.IsNotNull( capturedSource, "SourceHandler should have been called" );
Assert.IsTrue( capturedSource.Length > 0, "Captured source should not be empty" );
Assert.IsTrue( capturedSource.Contains( "__state<>" ),
"Captured source should contain state machine state field" );
Assert.IsTrue( capturedSource.Contains( "__builder<>" ),
"Captured source should contain state machine builder field" );
}

[TestMethod]
public async Task BlockAsync_SourceHandler_ShouldNotBeCalled_WhenNotProvided()
{
// Arrange - no SourceHandler set
var block = BlockAsync(
Await( AsyncHelper.Completer(
Constant( CompleterType.Immediate ),
Constant( 42 )
) )
);

var lambda = Lambda<Func<Task<int>>>( block );
var compiledLambda = lambda.Compile( CompilerType.Fast );

// Act - should complete without error
var result = await compiledLambda();

// Assert
Assert.AreEqual( 42, result );
}

// Helper: Custom test provider that tracks usage
private class CustomTestModuleBuilderProvider : IModuleBuilderProvider
{
Expand Down
32 changes: 30 additions & 2 deletions test/Hyperbee.Expressions.Tests/BlockAsyncBasicTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Linq.Expressions;
#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value

using System.Linq.Expressions;
using System.Reflection;
using Hyperbee.Expressions.Tests.TestSupport;
using static System.Linq.Expressions.Expression;
Expand Down Expand Up @@ -296,9 +298,9 @@

// Act
await compiledLambda(); // Should complete without exception

Check warning on line 301 in test/Hyperbee.Expressions.Tests/BlockAsyncBasicTests.cs

View workflow job for this annotation

GitHub Actions / test / test (net10.0)

Review or remove the assertion as its condition is known to be always true (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0032)
// Assert
Assert.IsTrue( true ); // If no exception, the test is successful
// If no exception, the test is successful
}

[TestMethod]
Expand Down Expand Up @@ -646,6 +648,32 @@
Assert.AreEqual( 2, result );
}

[TestMethod]
[DataRow( CompilerType.Fast )]
[DataRow( CompilerType.System )]
[DataRow( CompilerType.Interpret )]
public async Task BlockAsync_ShouldHandleValueTypeToObjectAssignment_WithBoxing( CompilerType compiler )
{
// Arrange: assign value-type (int) to variable before await
// Reproduces: https://github.com/Stillpoint-Software/hyperbee.expressions/issues/126
var variable = Variable( typeof( int ) );

var block = BlockAsync(
[variable],
Assign( variable, Constant( 0 ) ),
Await( Constant( Task.FromResult( new object() ) ) )
);

var lambda = Lambda<Func<Task<object>>>( block );
var compiledLambda = lambda.Compile( compiler );

// Act
var result = await compiledLambda();

// Assert
Assert.IsNotNull( result );
}

public class TestContext
{
public int Id { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,11 @@
var lambda = Lambda<Func<Task>>( block );
var compiledLambda = lambda.Compile( compiler );

// Act
// Act & Assert - test passes if no exception is thrown
await compiledLambda();

// Assert
Assert.IsTrue( true ); // No exception means condition and block executed successfully
}

[TestMethod]

Check warning on line 36 in test/Hyperbee.Expressions.Tests/BlockAsyncConditionalTests.cs

View workflow job for this annotation

GitHub Actions / test / test (net10.0)

Review or remove the assertion as its condition is known to be always true (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0032)

Check warning on line 36 in test/Hyperbee.Expressions.Tests/BlockAsyncConditionalTests.cs

View workflow job for this annotation

GitHub Actions / test / test (net8.0)

Review or remove the assertion as its condition is known to be always true (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0032)

Check warning on line 36 in test/Hyperbee.Expressions.Tests/BlockAsyncConditionalTests.cs

View workflow job for this annotation

GitHub Actions / test / test (net9.0)

Review or remove the assertion as its condition is known to be always true (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0032)
[DataRow( CompleterType.Immediate, CompilerType.Fast )]
[DataRow( CompleterType.Immediate, CompilerType.System )]
[DataRow( CompleterType.Immediate, CompilerType.Interpret )]
Expand Down
Loading
Loading