From 4d60ab427fb9975cd40c6f7b0d39d4c0f4d71929 Mon Sep 17 00:00:00 2001 From: Daniel Gerlag Date: Fri, 15 May 2026 17:05:57 -0700 Subject: [PATCH 1/3] 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> --- .../Services/DefinitionLoader.cs | 55 ++++++++++++++++--- src/WorkflowCore/Models/MemberMapParameter.cs | 20 ++++--- test/Directory.Build.props | 2 +- .../WorkflowCore.IntegrationTests.csproj | 2 +- .../WorkflowCore.UnitTests.csproj | 2 +- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/WorkflowCore.DSL/Services/DefinitionLoader.cs b/src/WorkflowCore.DSL/Services/DefinitionLoader.cs index 5d6595550..29465dbf9 100644 --- a/src/WorkflowCore.DSL/Services/DefinitionLoader.cs +++ b/src/WorkflowCore.DSL/Services/DefinitionLoader.cs @@ -318,12 +318,14 @@ private void AttachDirectlyOutput(KeyValuePair output, WorkflowS propertyInfo = dataType.GetProperty("Item"); targetProperty = Expression.Property(dataParameter, propertyInfo, Expression.Constant(output.Key)); + var compiledSourceExpr = sourceExpr.Compile(); + Action acn = (pStep, pData) => { object resolvedValue; try { - resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep); + resolvedValue = compiledSourceExpr.DynamicInvoke(pStep); } catch (TargetInvocationException ex) { @@ -377,13 +379,16 @@ private void AttachNestedOutput(KeyValuePair output, WorkflowSte } propertyInfo = ((PropertyInfo)memberExpression.Member).PropertyType.GetProperty("Item"); + var targetExpr = Expression.Lambda(memberExpression, dataParameter); + var compiledTargetExpr = targetExpr.Compile(); + var compiledSourceExpr = sourceExpr.Compile(); + Action acn = (pStep, pData) => { - var targetExpr = Expression.Lambda(memberExpression, dataParameter); object data; try { - data = targetExpr.Compile().DynamicInvoke(pData); + data = compiledTargetExpr.DynamicInvoke(pData); } catch (TargetInvocationException ex) { @@ -392,7 +397,7 @@ private void AttachNestedOutput(KeyValuePair output, WorkflowSte object resolvedValue; try { - resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep); + resolvedValue = compiledSourceExpr.DynamicInvoke(pStep); } catch (TargetInvocationException ex) { @@ -470,12 +475,14 @@ private static Action BuildScalarInput throw new WorkflowDefinitionLoadException($"Error parsing input expression '{expr}' for property '{input.Key}': {ex.Message}", ex); } + var compiledExpr = sourceExpr.Compile(); + void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) { object resolvedValue; try { - resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables()); + resolvedValue = compiledExpr.DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables()); } catch (TargetInvocationException ex) { @@ -505,6 +512,40 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) private static Action BuildObjectInputAction(KeyValuePair input, ParameterExpression dataParameter, ParameterExpression contextParameter, ParameterExpression environmentVarsParameter, PropertyInfo stepProperty) { + // Pre-compile all @-prefixed property expressions at definition load time + var compiledExpressions = new Dictionary(); + var templateObj = JObject.FromObject(input.Value); + var scanStack = new Stack(); + scanStack.Push(templateObj); + + while (scanStack.Count > 0) + { + var subobj = scanStack.Pop(); + foreach (var prop in subobj.Properties()) + { + if (prop.Name.StartsWith("@")) + { + var exprText = prop.Value.ToString(); + if (!compiledExpressions.ContainsKey(exprText)) + { + LambdaExpression sourceExpr; + try + { + sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(exprText)); + } + catch (Exception ex) when (ex is System.Linq.Dynamic.Core.Exceptions.ParseException || ex is InvalidOperationException) + { + throw new WorkflowDefinitionLoadException($"Error parsing input expression '{exprText}': {ex.Message}", ex); + } + compiledExpressions[exprText] = sourceExpr.Compile(); + } + } + } + + foreach (var child in subobj.Children()) + scanStack.Push(child); + } + void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) { var stack = new Stack(); @@ -518,11 +559,11 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) { if (prop.Name.StartsWith("@")) { - var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(prop.Value.ToString())); + var exprText = prop.Value.ToString(); object resolvedValue; try { - resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables()); + resolvedValue = compiledExpressions[exprText].DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables()); } catch (TargetInvocationException ex) { diff --git a/src/WorkflowCore/Models/MemberMapParameter.cs b/src/WorkflowCore/Models/MemberMapParameter.cs index e5273986d..13ae0f38e 100644 --- a/src/WorkflowCore/Models/MemberMapParameter.cs +++ b/src/WorkflowCore/Models/MemberMapParameter.cs @@ -9,6 +9,7 @@ public class MemberMapParameter : IStepParameter { private readonly LambdaExpression _source; private readonly LambdaExpression _target; + private readonly Delegate _compiledSource; public MemberMapParameter(LambdaExpression source, LambdaExpression target) { @@ -17,19 +18,20 @@ public MemberMapParameter(LambdaExpression source, LambdaExpression target) _source = source; _target = target; + _compiledSource = source.Compile(); } - private void Assign(object sourceObject, LambdaExpression sourceExpr, object targetObject, LambdaExpression targetExpr, IStepExecutionContext context) + private void Assign(object sourceObject, object targetObject, IStepExecutionContext context) { object resolvedValue = null; - switch (sourceExpr.Parameters.Count) + switch (_source.Parameters.Count) { case 1: - resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject); + resolvedValue = _compiledSource.DynamicInvoke(sourceObject); break; case 2: - resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject, context); + resolvedValue = _compiledSource.DynamicInvoke(sourceObject, context); break; default: throw new ArgumentException(); @@ -37,24 +39,24 @@ private void Assign(object sourceObject, LambdaExpression sourceExpr, object tar if (resolvedValue == null) { - var defaultAssign = Expression.Lambda(Expression.Assign(targetExpr.Body, Expression.Default(targetExpr.ReturnType)), targetExpr.Parameters.Single()); + var defaultAssign = Expression.Lambda(Expression.Assign(_target.Body, Expression.Default(_target.ReturnType)), _target.Parameters.Single()); defaultAssign.Compile().DynamicInvoke(targetObject); return; } - var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), targetExpr.ReturnType); - var assign = Expression.Lambda(Expression.Assign(targetExpr.Body, valueExpr), targetExpr.Parameters.Single()); + var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), _target.ReturnType); + var assign = Expression.Lambda(Expression.Assign(_target.Body, valueExpr), _target.Parameters.Single()); assign.Compile().DynamicInvoke(targetObject); } public void AssignInput(object data, IStepBody body, IStepExecutionContext context) { - Assign(data, _source, body, _target, context); + Assign(data, body, context); } public void AssignOutput(object data, IStepBody body, IStepExecutionContext context) { - Assign(body, _source, data, _target, context); + Assign(body, data, context); } } } diff --git a/test/Directory.Build.props b/test/Directory.Build.props index cd70cf018..1947c22b5 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,6 +1,6 @@ - net6.0;net8.0 + net6.0;net8.0;net10.0 latest false diff --git a/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj b/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj index c92182936..996a9831e 100644 --- a/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj +++ b/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj @@ -7,7 +7,7 @@ false false false - net6.0 + net6.0;net8.0;net10.0 diff --git a/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj b/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj index ff8f2e19e..4822cced6 100644 --- a/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj +++ b/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj @@ -7,7 +7,7 @@ false false false - net6.0 + net6.0;net8.0;net10.0 From 56206b8b0ad51c3b72fe72c6c2f02da558556e9e Mon Sep 17 00:00:00 2001 From: Daniel Gerlag Date: Wed, 15 Jul 2026 09:05:28 -0700 Subject: [PATCH 2/3] 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> --- .../DataTypes/ScalarInputData.cs | 7 +++ .../Steps/ScalarInputStep.cs | 17 ++++++++ .../DefinitionLoaderTests.cs | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 test/WorkflowCore.TestAssets/DataTypes/ScalarInputData.cs create mode 100644 test/WorkflowCore.TestAssets/Steps/ScalarInputStep.cs diff --git a/test/WorkflowCore.TestAssets/DataTypes/ScalarInputData.cs b/test/WorkflowCore.TestAssets/DataTypes/ScalarInputData.cs new file mode 100644 index 000000000..3fb36ec06 --- /dev/null +++ b/test/WorkflowCore.TestAssets/DataTypes/ScalarInputData.cs @@ -0,0 +1,7 @@ +namespace WorkflowCore.TestAssets.DataTypes +{ + public class ScalarInputData + { + public string MessageId { get; set; } + } +} diff --git a/test/WorkflowCore.TestAssets/Steps/ScalarInputStep.cs b/test/WorkflowCore.TestAssets/Steps/ScalarInputStep.cs new file mode 100644 index 000000000..3faed5b9b --- /dev/null +++ b/test/WorkflowCore.TestAssets/Steps/ScalarInputStep.cs @@ -0,0 +1,17 @@ +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.TestAssets.Steps +{ + public class ScalarInputStep : StepBody + { + public string MessageId { get; set; } + + public string Status { get; set; } + + public override ExecutionResult Run(IStepExecutionContext context) + { + return ExecutionResult.Next(); + } + } +} diff --git a/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs b/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs index c47ba054b..657f337a8 100644 --- a/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs +++ b/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs @@ -1,11 +1,13 @@ using FakeItEasy; using FluentAssertions; +using Newtonsoft.Json; using System; using System.Linq; using WorkflowCore.Interface; using WorkflowCore.Models; using WorkflowCore.Services.DefinitionStorage; using WorkflowCore.TestAssets.DataTypes; +using WorkflowCore.TestAssets.Steps; using Xunit; namespace WorkflowCore.UnitTests.Services.DefinitionStorage @@ -71,6 +73,47 @@ public void ParseDefinitionInputException() Assert.Throws(() => _subject.LoadDefinition(TestAssets.Utils.GetTestDefinitionJsonMissingInputProperty(), Deserializers.Json)); } + // Regression test for issue #1428: a scalar variable-binding input plus a + // scalar string-literal input. The compiled input expressions used to be + // built inside a closure and recompiled on every invocation, which produced + // an InvalidProgramException on .NET 10. Loading the definition and then + // assigning the inputs (as WorkflowExecutor.ExecuteStep does) must succeed + // and resolve both values. + [Fact(DisplayName = "Should evaluate scalar variable and string-literal inputs")] + public void ParseAndAssignScalarInputs() + { + var dataType = typeof(ScalarInputData).AssemblyQualifiedName; + var stepType = typeof(ScalarInputStep).AssemblyQualifiedName; + + var json = + "{" + + "\"Id\": \"Issue1428\", \"Version\": 1," + + "\"DataType\": " + JsonConvert.ToString(dataType) + "," + + "\"Steps\": [{" + + "\"Id\": \"UpdateStatus\"," + + "\"Name\": \"Update internal status\"," + + "\"StepType\": " + JsonConvert.ToString(stepType) + "," + + "\"Inputs\": {" + + "\"MessageId\": \"data.MessageId\"," + + "\"Status\": \"\\\"waits-for-batching\\\"\"" + + "}" + + "}]}"; + + var def = _subject.LoadDefinition(json, Deserializers.Json); + + var step = def.Steps.Single(s => s.ExternalId == "UpdateStatus"); + step.Inputs.Count.Should().Be(2); + + var body = new ScalarInputStep(); + var data = new ScalarInputData { MessageId = "msg-42" }; + + foreach (var input in step.Inputs) + input.AssignInput(data, body, null); + + body.MessageId.Should().Be("msg-42"); + body.Status.Should().Be("waits-for-batching"); + } + private bool MatchTestDefinition(WorkflowDefinition def) { //TODO: make this better From 329942644363577e731c481dedb5d2e930d86aa5 Mon Sep 17 00:00:00 2001 From: Daniel Gerlag Date: Wed, 15 Jul 2026 10:30:50 -0700 Subject: [PATCH 3/3] 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> --- .github/workflows/dotnet.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0a72e4cb2..e310b1023 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -303,8 +303,10 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | + 6.0.x 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build