Skip to content

Commit d022721

Browse files
Add .NET 10 support: fix InvalidProgramException in expression compilation (#1429)
* Add .NET 10 support: fix InvalidProgramException in expression compilation Pre-compile dynamic expressions outside closures/local functions in DefinitionLoader to avoid InvalidProgramException on .NET 10's updated expression tree compiler. The previous pattern of calling LambdaExpression.Compile() inside closures that capture expression objects produces invalid IL on .NET 10. Changes: - BuildScalarInputAction: compile expression before local function - BuildObjectInputAction: pre-scan JObject and compile all @-prefixed expressions at definition load time instead of at each invocation - AttachDirectlyOutput: pre-compile source expression before lambda - AttachNestedOutput: pre-compile both target and source expressions - MemberMapParameter: cache compiled source delegate in constructor - Add net10.0 to test TFMs (Directory.Build.props, UnitTests, IntegrationTests) Fixes #1428 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add regression test for scalar inputs (issue #1428) Covers the reporter's exact scenario: a scalar variable-binding input (data.MessageId) plus a scalar string-literal input ("waits-for-batching"). Loads the definition and assigns the inputs the same way WorkflowExecutor.ExecuteStep does, asserting both values resolve. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * ci: install .NET 10 SDK in Azure-Tests job The Azure-Tests job runs a repo-wide 'dotnet restore', which now fails with NETSDK1045 because the test projects target net10.0 but this job only installed the .NET 8/9 SDKs. Align it with the other jobs (6/8/9/10). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a33e5a9 commit d022721

9 files changed

Lines changed: 131 additions & 19 deletions

File tree

.github/workflows/dotnet.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,10 @@ jobs:
303303
uses: actions/setup-dotnet@v1
304304
with:
305305
dotnet-version: |
306+
6.0.x
306307
8.0.x
307308
9.0.x
309+
10.0.x
308310
- name: Restore dependencies
309311
run: dotnet restore
310312
- name: Build

src/WorkflowCore.DSL/Services/DefinitionLoader.cs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,14 @@ private void AttachDirectlyOutput(KeyValuePair<string, string> output, WorkflowS
318318
propertyInfo = dataType.GetProperty("Item");
319319
targetProperty = Expression.Property(dataParameter, propertyInfo, Expression.Constant(output.Key));
320320

321+
var compiledSourceExpr = sourceExpr.Compile();
322+
321323
Action<IStepBody, object> acn = (pStep, pData) =>
322324
{
323325
object resolvedValue;
324326
try
325327
{
326-
resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep);
328+
resolvedValue = compiledSourceExpr.DynamicInvoke(pStep);
327329
}
328330
catch (TargetInvocationException ex)
329331
{
@@ -377,13 +379,16 @@ private void AttachNestedOutput(KeyValuePair<string, string> output, WorkflowSte
377379
}
378380
propertyInfo = ((PropertyInfo)memberExpression.Member).PropertyType.GetProperty("Item");
379381

382+
var targetExpr = Expression.Lambda(memberExpression, dataParameter);
383+
var compiledTargetExpr = targetExpr.Compile();
384+
var compiledSourceExpr = sourceExpr.Compile();
385+
380386
Action<IStepBody, object> acn = (pStep, pData) =>
381387
{
382-
var targetExpr = Expression.Lambda(memberExpression, dataParameter);
383388
object data;
384389
try
385390
{
386-
data = targetExpr.Compile().DynamicInvoke(pData);
391+
data = compiledTargetExpr.DynamicInvoke(pData);
387392
}
388393
catch (TargetInvocationException ex)
389394
{
@@ -392,7 +397,7 @@ private void AttachNestedOutput(KeyValuePair<string, string> output, WorkflowSte
392397
object resolvedValue;
393398
try
394399
{
395-
resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep);
400+
resolvedValue = compiledSourceExpr.DynamicInvoke(pStep);
396401
}
397402
catch (TargetInvocationException ex)
398403
{
@@ -470,12 +475,14 @@ private static Action<IStepBody, object, IStepExecutionContext> BuildScalarInput
470475
throw new WorkflowDefinitionLoadException($"Error parsing input expression '{expr}' for property '{input.Key}': {ex.Message}", ex);
471476
}
472477

478+
var compiledExpr = sourceExpr.Compile();
479+
473480
void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
474481
{
475482
object resolvedValue;
476483
try
477484
{
478-
resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
485+
resolvedValue = compiledExpr.DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
479486
}
480487
catch (TargetInvocationException ex)
481488
{
@@ -505,6 +512,40 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
505512

506513
private static Action<IStepBody, object, IStepExecutionContext> BuildObjectInputAction(KeyValuePair<string, object> input, ParameterExpression dataParameter, ParameterExpression contextParameter, ParameterExpression environmentVarsParameter, PropertyInfo stepProperty)
507514
{
515+
// Pre-compile all @-prefixed property expressions at definition load time
516+
var compiledExpressions = new Dictionary<string, Delegate>();
517+
var templateObj = JObject.FromObject(input.Value);
518+
var scanStack = new Stack<JObject>();
519+
scanStack.Push(templateObj);
520+
521+
while (scanStack.Count > 0)
522+
{
523+
var subobj = scanStack.Pop();
524+
foreach (var prop in subobj.Properties())
525+
{
526+
if (prop.Name.StartsWith("@"))
527+
{
528+
var exprText = prop.Value.ToString();
529+
if (!compiledExpressions.ContainsKey(exprText))
530+
{
531+
LambdaExpression sourceExpr;
532+
try
533+
{
534+
sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(exprText));
535+
}
536+
catch (Exception ex) when (ex is System.Linq.Dynamic.Core.Exceptions.ParseException || ex is InvalidOperationException)
537+
{
538+
throw new WorkflowDefinitionLoadException($"Error parsing input expression '{exprText}': {ex.Message}", ex);
539+
}
540+
compiledExpressions[exprText] = sourceExpr.Compile();
541+
}
542+
}
543+
}
544+
545+
foreach (var child in subobj.Children<JObject>())
546+
scanStack.Push(child);
547+
}
548+
508549
void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
509550
{
510551
var stack = new Stack<JObject>();
@@ -518,11 +559,11 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
518559
{
519560
if (prop.Name.StartsWith("@"))
520561
{
521-
var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(prop.Value.ToString()));
562+
var exprText = prop.Value.ToString();
522563
object resolvedValue;
523564
try
524565
{
525-
resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
566+
resolvedValue = compiledExpressions[exprText].DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
526567
}
527568
catch (TargetInvocationException ex)
528569
{

src/WorkflowCore/Models/MemberMapParameter.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public class MemberMapParameter : IStepParameter
99
{
1010
private readonly LambdaExpression _source;
1111
private readonly LambdaExpression _target;
12+
private readonly Delegate _compiledSource;
1213

1314
public MemberMapParameter(LambdaExpression source, LambdaExpression target)
1415
{
@@ -17,44 +18,45 @@ public MemberMapParameter(LambdaExpression source, LambdaExpression target)
1718

1819
_source = source;
1920
_target = target;
21+
_compiledSource = source.Compile();
2022
}
2123

22-
private void Assign(object sourceObject, LambdaExpression sourceExpr, object targetObject, LambdaExpression targetExpr, IStepExecutionContext context)
24+
private void Assign(object sourceObject, object targetObject, IStepExecutionContext context)
2325
{
2426
object resolvedValue = null;
2527

26-
switch (sourceExpr.Parameters.Count)
28+
switch (_source.Parameters.Count)
2729
{
2830
case 1:
29-
resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject);
31+
resolvedValue = _compiledSource.DynamicInvoke(sourceObject);
3032
break;
3133
case 2:
32-
resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject, context);
34+
resolvedValue = _compiledSource.DynamicInvoke(sourceObject, context);
3335
break;
3436
default:
3537
throw new ArgumentException();
3638
}
3739

3840
if (resolvedValue == null)
3941
{
40-
var defaultAssign = Expression.Lambda(Expression.Assign(targetExpr.Body, Expression.Default(targetExpr.ReturnType)), targetExpr.Parameters.Single());
42+
var defaultAssign = Expression.Lambda(Expression.Assign(_target.Body, Expression.Default(_target.ReturnType)), _target.Parameters.Single());
4143
defaultAssign.Compile().DynamicInvoke(targetObject);
4244
return;
4345
}
4446

45-
var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), targetExpr.ReturnType);
46-
var assign = Expression.Lambda(Expression.Assign(targetExpr.Body, valueExpr), targetExpr.Parameters.Single());
47+
var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), _target.ReturnType);
48+
var assign = Expression.Lambda(Expression.Assign(_target.Body, valueExpr), _target.Parameters.Single());
4749
assign.Compile().DynamicInvoke(targetObject);
4850
}
4951

5052
public void AssignInput(object data, IStepBody body, IStepExecutionContext context)
5153
{
52-
Assign(data, _source, body, _target, context);
54+
Assign(data, body, context);
5355
}
5456

5557
public void AssignOutput(object data, IStepBody body, IStepExecutionContext context)
5658
{
57-
Assign(body, _source, data, _target, context);
59+
Assign(body, data, context);
5860
}
5961
}
6062
}

test/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
3+
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
44
<LangVersion>latest</LangVersion>
55
<IsPackable>false</IsPackable>
66
<IsTestProject>true</IsTestProject>

test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
88
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
99
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
10-
<TargetFrameworks>net6.0</TargetFrameworks>
10+
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
1111
</PropertyGroup>
1212

1313
<ItemGroup>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace WorkflowCore.TestAssets.DataTypes
2+
{
3+
public class ScalarInputData
4+
{
5+
public string MessageId { get; set; }
6+
}
7+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using WorkflowCore.Interface;
2+
using WorkflowCore.Models;
3+
4+
namespace WorkflowCore.TestAssets.Steps
5+
{
6+
public class ScalarInputStep : StepBody
7+
{
8+
public string MessageId { get; set; }
9+
10+
public string Status { get; set; }
11+
12+
public override ExecutionResult Run(IStepExecutionContext context)
13+
{
14+
return ExecutionResult.Next();
15+
}
16+
}
17+
}

test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using FakeItEasy;
22
using FluentAssertions;
3+
using Newtonsoft.Json;
34
using System;
45
using System.Linq;
56
using WorkflowCore.Interface;
67
using WorkflowCore.Models;
78
using WorkflowCore.Services.DefinitionStorage;
89
using WorkflowCore.TestAssets.DataTypes;
10+
using WorkflowCore.TestAssets.Steps;
911
using Xunit;
1012

1113
namespace WorkflowCore.UnitTests.Services.DefinitionStorage
@@ -71,6 +73,47 @@ public void ParseDefinitionInputException()
7173
Assert.Throws<ArgumentException>(() => _subject.LoadDefinition(TestAssets.Utils.GetTestDefinitionJsonMissingInputProperty(), Deserializers.Json));
7274
}
7375

76+
// Regression test for issue #1428: a scalar variable-binding input plus a
77+
// scalar string-literal input. The compiled input expressions used to be
78+
// built inside a closure and recompiled on every invocation, which produced
79+
// an InvalidProgramException on .NET 10. Loading the definition and then
80+
// assigning the inputs (as WorkflowExecutor.ExecuteStep does) must succeed
81+
// and resolve both values.
82+
[Fact(DisplayName = "Should evaluate scalar variable and string-literal inputs")]
83+
public void ParseAndAssignScalarInputs()
84+
{
85+
var dataType = typeof(ScalarInputData).AssemblyQualifiedName;
86+
var stepType = typeof(ScalarInputStep).AssemblyQualifiedName;
87+
88+
var json =
89+
"{" +
90+
"\"Id\": \"Issue1428\", \"Version\": 1," +
91+
"\"DataType\": " + JsonConvert.ToString(dataType) + "," +
92+
"\"Steps\": [{" +
93+
"\"Id\": \"UpdateStatus\"," +
94+
"\"Name\": \"Update internal status\"," +
95+
"\"StepType\": " + JsonConvert.ToString(stepType) + "," +
96+
"\"Inputs\": {" +
97+
"\"MessageId\": \"data.MessageId\"," +
98+
"\"Status\": \"\\\"waits-for-batching\\\"\"" +
99+
"}" +
100+
"}]}";
101+
102+
var def = _subject.LoadDefinition(json, Deserializers.Json);
103+
104+
var step = def.Steps.Single(s => s.ExternalId == "UpdateStatus");
105+
step.Inputs.Count.Should().Be(2);
106+
107+
var body = new ScalarInputStep();
108+
var data = new ScalarInputData { MessageId = "msg-42" };
109+
110+
foreach (var input in step.Inputs)
111+
input.AssignInput(data, body, null);
112+
113+
body.MessageId.Should().Be("msg-42");
114+
body.Status.Should().Be("waits-for-batching");
115+
}
116+
74117
private bool MatchTestDefinition(WorkflowDefinition def)
75118
{
76119
//TODO: make this better

test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
88
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
99
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
10-
<TargetFrameworks>net6.0</TargetFrameworks>
10+
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
1111
</PropertyGroup>
1212

1313
<ItemGroup>

0 commit comments

Comments
 (0)