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,56 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Stack-depth guard injected into compiled user scripts (lmxopcua#779). <c>ScriptEvaluator.Compile</c> inserts a
/// call to <see cref="Check"/> as the first statement of the script body and of every lambda, anonymous method
/// and local function the script declares, so every frame of a recursive cycle that runs script code passes
/// through it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> A <see cref="StackOverflowException"/> cannot be caught in .NET: the runtime terminates the
/// process. One script with unbounded recursion (a local function, a self-referencing <c>Func&lt;&gt;</c>, an
/// anonymous self-application) would take down the whole server — every OPC UA session, every driver, the
/// node's Akka membership. <see cref="RuntimeHelpers.TryEnsureSufficientExecutionStack"/> reports false while
/// there is still a safety margin of stack left, so a <em>catchable</em> exception is thrown instead: the
/// script fails like any other throwing script (Bad quality, script log) and the process lives.
/// </para>
/// <para>
/// <b>Limits.</b> The check runs on entry to script-declared code only. A recursion that never re-enters
/// script code (a BCL call that recurses on its own) is not seen — the sandbox gives scripts no such API
/// today. Frames of BCL code between two script frames (a LINQ operator invoking a script lambda) must fit
/// in the margin; they are a few hundred bytes. The check is per frame, so it bounds depth by the stack
/// that remains, not by a fixed count: the same script may fail at a different depth on a thread with a
/// different stack size (the platform's default thread stack size differs between Windows, Linux and macOS).
/// </para>
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class ScriptStackGuard
{
/// <summary>Throws <see cref="ScriptStackExhaustedException"/> when the current thread is close to the end of its
/// stack. Called by compiled scripts; not part of the script authoring surface.</summary>
/// <exception cref="ScriptStackExhaustedException">The remaining stack is below the runtime's safety margin.</exception>
public static void Check()
{
if (!RuntimeHelpers.TryEnsureSufficientExecutionStack())
throw new ScriptStackExhaustedException();
}
}
/// <summary>
/// A script recursed (or nested calls) deep enough to exhaust its thread's stack. Thrown by
/// <see cref="ScriptStackGuard.Check"/> before the runtime would raise an uncatchable
/// <see cref="StackOverflowException"/>; the evaluating seam reports it like any other script exception.
/// </summary>
public sealed class ScriptStackExhaustedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="ScriptStackExhaustedException"/> class.</summary>
public ScriptStackExhaustedException()
: base("Script stack exhausted: unbounded or too-deep recursion. The script was stopped before it could " +
"overflow the stack and terminate the server.")
{
}
}
@@ -34,7 +34,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// sandbox escapes that slipped past reference restrictions due to .NET's type
/// forwarding, throwing <see cref="ScriptSandboxViolationException"/>; (3) emit
/// to an in-memory PE stream + load into the collectible ALC — throws at this
/// layer only for internal Roslyn bugs, not user error.
/// layer only for internal Roslyn bugs, not user error. Between (2) and (3) the tree is rewritten to
/// call <see cref="ScriptStackGuard.Check"/> on entry to every script-declared body, so unbounded
/// recursion fails the script instead of overflowing the stack and terminating the process
/// (<see cref="ScriptStackGuardInjector"/>, lmxopcua#779).
/// </para>
/// <para>
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
@@ -120,6 +123,14 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
if (rejections.Count > 0)
throw new ScriptSandboxViolationException(rejections);
// Step 3a — stack-depth guard (lmxopcua#779). Insert ScriptStackGuard.Check() at the entry of the script
// body and of every lambda / anonymous method / local function, so unbounded recursion fails the script with
// a catchable exception instead of a StackOverflowException that terminates the server. After the analyzer,
// so the analyzer and its diagnostics only ever see the user's code. The rewrite is designed to bind exactly
// as the original did; an emit failure here is an internal defect, reported like any other emit failure.
var guardedTree = ScriptStackGuardInjector.Inject(compilation, syntaxTree);
compilation = compilation.ReplaceSyntaxTree(syntaxTree, guardedTree);
// Step 4 — emit to an in-memory PE stream and load into a collectible ALC.
using var peStream = new MemoryStream();
var emitResult = compilation.Emit(peStream);
@@ -0,0 +1,121 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Rewrites the synthesized script wrapper so that the script body and every lambda, anonymous method and local
/// function in it starts with <c>ScriptStackGuard.Check();</c> (lmxopcua#779). Any recursion that runs script code —
/// direct, mutual, through a self-referencing delegate, through an anonymous self-application, or through a BCL
/// call that invokes a script lambda — therefore passes a check on every script frame, and fails with a catchable
/// <see cref="ScriptStackExhaustedException"/> instead of an uncatchable <see cref="StackOverflowException"/>
/// that would terminate the server.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a runtime check and not an analyzer rule rejecting recursion.</b> Recursion cannot be decided from
/// syntax: <c>Func&lt;object,int&gt; g = s =&gt; ((Func&lt;object,int&gt;)s)(s); g(g);</c> recurses without
/// ever naming itself, and a delegate can reach itself through a collection or a captured variable. A rule
/// would reject the obvious shapes and pass the rest. The check also leaves legitimate bounded recursion
/// (a short tree walk) working.
/// </para>
/// <para>
/// <b>Semantics preserved.</b> An expression-bodied lambda or local function becomes a block — <c>{ Check();
/// return e; }</c>, or <c>{ Check(); e; }</c> when the bound delegate/method returns void. The void-or-not
/// decision is read from the semantic model of the already-validated compilation, i.e. from the overload the
/// compiler actually picked, so the block form is compatible with the same delegate type and nothing
/// re-binds differently. Lambdas converted to an expression tree (<c>Expression&lt;T&gt;</c>) are left alone:
/// a block body is not legal there and an expression tree does not execute script frames. Async lambdas and
/// local functions are left alone (the sandbox denies <c>System.Threading.Tasks</c>, so a script cannot
/// produce one that compiles).
/// </para>
/// <para>
/// Runs after <see cref="ForbiddenTypeAnalyzer"/>, so the analyzer only ever sees the user's code; the
/// inserted call names a type in <c>Core.Scripting.Abstractions</c>, which the sandbox already references.
/// </para>
/// </remarks>
internal static class ScriptStackGuardInjector
{
private const string CheckCall = "global::ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptStackGuard.Check()";
/// <summary>Returns <paramref name="tree"/> with a stack check at the entry of every script-declared body.</summary>
/// <param name="compilation">The compiled (error-free, analyzer-clean) wrapper compilation.</param>
/// <param name="tree">The wrapper syntax tree inside <paramref name="compilation"/>.</param>
/// <returns>The rewritten tree, with the original parse options.</returns>
public static SyntaxTree Inject(CSharpCompilation compilation, SyntaxTree tree)
{
var model = compilation.GetSemanticModel(tree);
var newRoot = new Rewriter(model).Visit(tree.GetRoot());
return tree.WithRootAndOptions(newRoot, tree.Options);
}
private static StatementSyntax CheckStatement() =>
ExpressionStatement(ParseExpression(CheckCall));
private static BlockSyntax Prepend(BlockSyntax block) =>
block.WithStatements(block.Statements.Insert(0, CheckStatement()));
private static BlockSyntax ToBlock(ExpressionSyntax body, bool returnsVoid) =>
Block(
CheckStatement(),
returnsVoid ? ExpressionStatement(body) : ReturnStatement(body));
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
{
public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node)
{
// The only method in the wrapper is the synthesized CompiledScript.Run (ScriptEvaluator enforces that).
var visited = (MethodDeclarationSyntax)base.VisitMethodDeclaration(node)!;
return visited.Body is { } body ? visited.WithBody(Prepend(body)) : visited;
}
public override SyntaxNode? VisitLocalFunctionStatement(LocalFunctionStatementSyntax node)
{
var symbol = model.GetDeclaredSymbol(node) as IMethodSymbol;
var visited = (LocalFunctionStatementSyntax)base.VisitLocalFunctionStatement(node)!;
if (symbol is null || symbol.IsAsync) return visited;
if (visited.Body is { } body) return visited.WithBody(Prepend(body));
if (visited.ExpressionBody is { } arrow)
{
return visited
.WithExpressionBody(null)
.WithSemicolonToken(default)
.WithBody(ToBlock(arrow.Expression, symbol.ReturnsVoid));
}
return visited;
}
public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) =>
Guard(node, (AnonymousFunctionExpressionSyntax)base.VisitSimpleLambdaExpression(node)!);
public override SyntaxNode? VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) =>
Guard(node, (AnonymousFunctionExpressionSyntax)base.VisitParenthesizedLambdaExpression(node)!);
public override SyntaxNode? VisitAnonymousMethodExpression(AnonymousMethodExpressionSyntax node) =>
Guard(node, (AnonymousFunctionExpressionSyntax)base.VisitAnonymousMethodExpression(node)!);
/// <param name="original">The node in the bound tree (semantic queries must use it).</param>
/// <param name="visited">The same node with its descendants already rewritten.</param>
private SyntaxNode Guard(AnonymousFunctionExpressionSyntax original, AnonymousFunctionExpressionSyntax visited)
{
if (original.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword)) return visited;
if (IsExpressionTree(original)) return visited;
if (model.GetSymbolInfo(original).Symbol is not IMethodSymbol symbol) return visited;
if (visited.Block is { } block) return visited.WithBlock(Prepend(block)).WithExpressionBody(null);
if (visited.ExpressionBody is { } expression)
return visited.WithExpressionBody(null).WithBlock(ToBlock(expression, symbol.ReturnsVoid));
return visited;
}
private bool IsExpressionTree(AnonymousFunctionExpressionSyntax lambda)
{
var converted = model.GetTypeInfo(lambda).ConvertedType;
return converted is INamedTypeSymbol { Name: "Expression", IsGenericType: true } named
&& named.ContainingNamespace?.ToDisplayString() == "System.Linq.Expressions";
}
}
}