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:
Joseph Doherty
2026-11-09 17:26:31 -05:00
parent a84d38707b
commit 9e8b5b0ea7
8 changed files with 390 additions and 3 deletions
@@ -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);
}
}