fix(scripting): stop unbounded script recursion from killing the server (lmxopcua#779)
Reproduced first, out of process (ScriptRecursionContainmentTests re-launches the xunit v3 test assembly to run one recursive script through ScriptEvaluator + ScriptRunGuard, so the runner never executes one): before this change every shape — local function, mutual local functions, self-referencing Func<>, anonymous self-application, recursion through LINQ, an expression-bodied void lambda, an anonymous method — ended the child with exit 134 (SIGABRT, "Stack overflow."). A StackOverflowException cannot be caught, so in production one script takes the whole node down. Fix: ScriptEvaluator.Compile now rewrites the validated wrapper (ScriptStackGuardInjector, after ForbiddenTypeAnalyzer) so the script body and every lambda, anonymous method and local function starts with ScriptStackGuard.Check(), which throws ScriptStackExhaustedException when RuntimeHelpers.TryEnsureSufficientExecutionStack reports the stack nearly spent. The script then fails like any throwing script (Bad, script log) and the process lives. Why a runtime check, not an analyzer rule: recursion is not decidable from syntax (anonymous self-application never names itself), and bounded recursion must keep working. Expression-bodied lambdas/local functions become blocks (`return e;` or `e;`, chosen from the bound symbol, so the same overload binds); expression-tree and async lambdas are left alone. Limits (documented on ScriptStackGuard and in docs/VirtualTags.md): depth depends on the thread's stack size; a recursion that never re-enters script code is not seen; a script may catch the exception, which only unwinds it. Tests: ScriptRecursionContainmentTests (7 shapes, child process each; all 7 fail with exit 134 when the rewrite is disabled), ScriptStackGuardInjectionTests (bounded recursion and every rewritten lambda/local-function shape keep their value; the expression-tree case fails if those lambdas are rewritten). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#779 — the stack-guard rewrite (<see cref="ScriptStackGuardInjector"/>) must not change what a script
|
||||
/// means: every lambda / local-function shape still binds to the same overload and returns the same value, and
|
||||
/// bounded recursion keeps working. The process-level proof that unbounded recursion no longer kills the host is
|
||||
/// <see cref="ScriptRecursionContainmentTests"/>; this class runs only scripts that terminate.
|
||||
/// </summary>
|
||||
public sealed class ScriptStackGuardInjectionTests
|
||||
{
|
||||
private static object? Run(string source)
|
||||
{
|
||||
using var evaluator = ScriptEvaluator<FakeScriptContext, object?>.Compile(source);
|
||||
return evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken).Result;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("int F(int n) => n <= 0 ? 0 : F(n - 1) + 1; return F(500);", 500)]
|
||||
[InlineData("int F(int n) { if (n <= 0) return 0; return F(n - 1) + 1; } return F(500);", 500)]
|
||||
[InlineData("System.Func<int, int> f = null!; f = n => n <= 0 ? 0 : f(n - 1) + 1; return f(500);", 500)]
|
||||
[InlineData("System.Func<int, int> f = null!; f = delegate (int n) { return n <= 0 ? 0 : f(n - 1) + 1; }; return f(500);", 500)]
|
||||
public void Bounded_recursion_still_returns_its_value(string source, int expected) =>
|
||||
Run(source).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
// Expression lambdas through LINQ: Select / Where / Sum / Aggregate overloads resolve as before.
|
||||
[InlineData("return System.Linq.Enumerable.Sum(System.Linq.Enumerable.Select(new[] { 1, 2, 3 }, x => x * 2));", 12)]
|
||||
[InlineData("return System.Linq.Enumerable.Count(new[] { 1, 2, 3, 4 }, x => x % 2 == 0);", 2)]
|
||||
[InlineData("return System.Linq.Enumerable.Aggregate(new[] { 1, 2, 3 }, 10, (acc, x) => acc + x);", 16)]
|
||||
// An expression-bodied VOID lambda (List.ForEach takes an Action): rewritten to `{ Check(); e; }`.
|
||||
[InlineData("var acc = new System.Collections.Generic.List<int>(); new System.Collections.Generic.List<int> { 1, 2 }.ForEach(x => acc.Add(x * 5)); return acc[1];", 10)]
|
||||
// Expression-bodied void local function, and a local function returning a lambda (nested rewrite).
|
||||
[InlineData("var box = new int[1]; void Set(int v) => box[0] = v; Set(7); return box[0];", 7)]
|
||||
[InlineData("System.Func<int, int> Make(int k) => x => x + k; return Make(3)(4);", 7)]
|
||||
// Static lambda and a lambda capturing ctx.
|
||||
[InlineData("System.Func<int, int> f = static x => x - 1; return f(9);", 8)]
|
||||
[InlineData("System.Func<string, bool> known = p => ctx.GetTag(p).StatusCode == 0u; return known(\"missing\") ? 1 : 0;", 0)]
|
||||
public void Rewritten_lambdas_and_local_functions_keep_their_meaning(string source, int expected) =>
|
||||
Run(source).ShouldBe(expected);
|
||||
|
||||
/// <summary>A lambda converted to an expression tree is left as it is (a block body is illegal there and an
|
||||
/// expression tree runs no script frame); rewriting it would turn a compiling script into an emit failure.</summary>
|
||||
[Fact]
|
||||
public void An_expression_tree_lambda_is_not_rewritten()
|
||||
{
|
||||
Run("System.Linq.Expressions.Expression<System.Func<int, int>> e = x => x + 1; return (int)e.Body.NodeType;")
|
||||
.ShouldBe((int)System.Linq.Expressions.ExpressionType.Add);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user