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
+6
View File
@@ -211,6 +211,12 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`:
`Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the
run returns, so a run that OOMs the process first is not stopped — that needs an
out-of-process runner (→ #750).
- **Recursion.** Unbounded recursion fails the script with
`ScriptStackExhaustedException` (Bad, script log) instead of overflowing the
stack, which would terminate the whole server. The runtime compile inserts a
stack check at the entry of the script and of every lambda, anonymous method
and local function (lmxopcua#779); the editor does not show it and does not
flag recursion, and bounded recursion still works.
- **Configuration keys and their ranges.** All four knobs are node-wide (the
`Scripting` appsettings section, shared by virtual tags, Calculation tags and
scripted alarms) and are clamped when bound, with one startup Warning per
+1 -1
View File
@@ -37,7 +37,7 @@ User scripts are compiled via `Microsoft.CodeAnalysis.CSharp` (regular compiler,
#### Known resource limits (accepted trade-offs)
The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocation is sampled (`GC.GetAllocatedBytesForCurrentThread`) and a run over `Scripting:MaxAllocBytesPerRun` (default 64 MB) quarantines the script — but the sample is taken after the run returns, so a script that allocates fast enough (`new byte[int.MaxValue]` in a loop) can still OOM the process first. **CPU:** a tight loop keeps its pool thread after the timeout fires; `ScriptRunGuard` bounds that to one orphan per script by refusing further runs while it spins (Bad `"previous evaluation still running"`) and quarantines after three consecutive timeouts (a refusal is not a strike) — or immediately on a refusal behind an orphan that has run longer than `Scripting:StuckOrphanQuarantineFactor` (default 10) × the timeout, which is what quarantines a never-returning `while(true)`. Both are mitigations; pre-emptive CPU/memory limits need an out-of-process runner (→ #750). Script authoring is gated behind the Admin permission as the primary control. See `docs/ScriptEditor.md` §"Runtime safety".
The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocation is sampled (`GC.GetAllocatedBytesForCurrentThread`) and a run over `Scripting:MaxAllocBytesPerRun` (default 64 MB) quarantines the script — but the sample is taken after the run returns, so a script that allocates fast enough (`new byte[int.MaxValue]` in a loop) can still OOM the process first. **CPU:** a tight loop keeps its pool thread after the timeout fires; `ScriptRunGuard` bounds that to one orphan per script by refusing further runs while it spins (Bad `"previous evaluation still running"`) and quarantines after three consecutive timeouts (a refusal is not a strike) — or immediately on a refusal behind an orphan that has run longer than `Scripting:StuckOrphanQuarantineFactor` (default 10) × the timeout, which is what quarantines a never-returning `while(true)`. Both are mitigations; pre-emptive CPU/memory limits need an out-of-process runner (→ #750). **Stack:** unbounded recursion used to terminate the whole server — a `StackOverflowException` cannot be caught, and lmxopcua#779 reproduced it out of process (local function, mutual recursion, self-referencing `Func<>`, anonymous self-application, recursion through LINQ: the child process died with "Stack overflow." / SIGABRT). `ScriptEvaluator.Compile` now rewrites the script so the body and every lambda, anonymous method and local function starts with `ScriptStackGuard.Check()` (`RuntimeHelpers.TryEnsureSufficientExecutionStack`), so the recursion fails the script with `ScriptStackExhaustedException` — Bad quality and a script-log entry like any throwing script — while there is still stack left. A runtime check rather than an analyzer rule because recursion is not decidable from syntax (anonymous self-application never names itself); bounded recursion keeps working. Limits: the depth reached depends on the evaluating thread's stack size; a recursion that never re-enters script code is not seen (the sandbox exposes no such API); a script can catch the exception, which only unwinds it. Pinned by `ScriptRecursionContainmentTests` (one child process per shape) and `ScriptStackGuardInjectionTests`. Script authoring is gated behind the Admin permission as the primary control. See `docs/ScriptEditor.md` §"Runtime safety".
`System.Threading.Tasks` is denied (Core.Scripting-003), which prevents `Task.Run` / `Parallel` fan-out that would spawn background work outliving the timeout.
@@ -88,7 +88,7 @@ used only by single-shot callers (`:18-27`).
|---|---|---|
| `while(true){}` (ignores the token) | The pool thread spins **forever**, holding one core and its collectible ALC. The guard bounds this to one orphan per key and then quarantines, but the thread keeps spinning until the process restarts. N such scripts peg N cores. | `ScriptRunGuard.cs:17-22` remarks; `ScriptRunGuardTests.cs:170-205` asserts *exactly one* orphan stays in flight (`InFlightCount.ShouldBe(1)`) |
| Fast allocation (`new byte[int.MaxValue]` in a loop, or a growing list) | The sample is taken **after** the delegate returns, so the process can OOM first. The whole server dies, including OPC UA sessions and every driver. | `ScriptRunGuard.cs:39-43`; `docs/VirtualTags.md:40`; review `docs/reviews/2026-10-21/05-core.md:100-105` |
| Unbounded recursion (a local function or a self-referencing `Func<>`; neither is a class member, so `EnforceSingleRunMember` does not see it) | `StackOverflowException` cannot be caught in .NET and **terminates the host process**. **[A-1]** I did not reproduce this; it follows from runtime semantics, and no analyzer rule targets recursion (`git grep -i "recursion\|StackOverflow"` in `Core.Scripting` finds nothing). | `ScriptEvaluator.cs:228-281` (member check only) |
| Unbounded recursion (a local function or a self-referencing `Func<>`; neither is a class member, so `EnforceSingleRunMember` does not see it) | `StackOverflowException` cannot be caught in .NET and **terminates the host process**. **[A-1]** I did not reproduce this; it follows from runtime semantics, and no analyzer rule targets recursion (`git grep -i "recursion\|StackOverflow"` in `Core.Scripting` finds nothing). **Update (lmxopcua#779):** reproduced out of process (exit 134, "Stack overflow.") and fixed in-process: the compile injects `ScriptStackGuard.Check()` into every script body, so the script now fails with a catchable `ScriptStackExhaustedException`. | `ScriptEvaluator.cs:228-281` (member check only) |
| Collectible-ALC reclaim of a stuck script | `ClearCompiledScripts` disposes the evaluator, but an ALC cannot be collected while a thread is executing its code, so a stuck orphan pins its assembly until restart. **[A-2]** This is .NET ALC semantics, not observed here. | `CompiledScriptCache.Clear`; `ScriptEvaluator.Dispose` |
| A slow (not runaway) script | VT: blocks a default-dispatcher thread for up to 2 s per evaluation. Calc / alarm: holds the driver-wide or engine-wide gate, so every sibling waits behind it (up to 2 s / 250 ms each). | `RoslynVirtualTagEvaluator.cs:174-175`; `CalculationDriver.cs:539-541`; `ScriptedAlarmEngine.cs:743-765` |
@@ -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";
}
}
}
@@ -0,0 +1,141 @@
using System.Diagnostics;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// lmxopcua#779 — can unbounded recursion in a user script take the whole host process down? A
/// <see cref="StackOverflowException"/> cannot be caught in .NET, so the only safe way to ask is in a
/// <b>separate process</b>: each case re-launches this test assembly (an xunit v3 executable) to run exactly one
/// <see cref="Child_runs_one_recursive_script_through_the_production_run_path"/> case, and inspects how that
/// process ended. The test runner itself never executes a recursive script.
/// </summary>
/// <remarks>
/// Before the guard every case below ended the child with "Stack overflow." and SIGABRT (exit 134 on macOS) — the
/// reproduction recorded in the #779 commit. Now the script fails with
/// <see cref="ScriptStackExhaustedException"/>, which the run path reports as an ordinary script fault, and the
/// process lives on.
/// </remarks>
public sealed class ScriptRecursionContainmentTests
{
private const string ScenarioVariable = "OTOPCUA_RECURSION_CHILD_SCENARIO";
private const string ResultFileVariable = "OTOPCUA_RECURSION_CHILD_RESULT";
/// <summary>Recursive shapes a script can write. None of them is a class member, so the wrapper's
/// single-member check never sees them; the last two have no named self-reference at all.</summary>
public static readonly Dictionary<string, string> Scenarios = new(StringComparer.Ordinal)
{
["local-function"] =
"int Down(int n) => Down(n + 1) + 1; return Down(0);",
["mutual-local-functions"] =
"int Ping(int n) { return Pong(n + 1) + 1; } int Pong(int n) { return Ping(n + 1) + 1; } return Ping(0);",
["self-referencing-lambda"] =
"System.Func<int, int> f = null!; f = n => f(n + 1) + 1; return f(0);",
["anonymous-self-application"] =
"System.Func<object, int> g = s => ((System.Func<object, int>)s)(s) + 1; return g(g);",
["recursion-through-linq"] =
"System.Func<int, int> f = null!; f = n => System.Linq.Enumerable.Sum(new[] { n }, x => f(x + 1)) + 1; return f(0);",
// An expression-bodied VOID lambda (rewritten to `{ Check(); e; }`), recursing through a value lambda. A
// bare `a = n => a(n + 1)` is no test: the JIT turns that tail call into a loop, which never overflows and
// is the timeout's business.
["void-lambda"] =
"System.Action<int> a = null!; System.Func<int, int> h = n => { a(n); return n; }; a = n => h(n + 1); a(0); return 0;",
["anonymous-method"] =
"System.Func<int, int> f = null!; f = delegate (int n) { return f(n + 1) + 1; }; return f(0);",
};
public static TheoryData<string> ScenarioNames => new(Scenarios.Keys);
[Theory]
[MemberData(nameof(ScenarioNames))]
public async Task A_recursive_script_fails_the_script_and_does_not_kill_the_process(string scenario)
{
var resultFile = Path.Combine(Path.GetTempPath(), $"otopcua-recursion-{Guid.NewGuid():N}.txt");
try
{
var (exitCode, stdout, stderr) = await RunChildAsync(scenario, resultFile);
var outcome = File.Exists(resultFile) ? await File.ReadAllTextAsync(resultFile, TestContext.Current.CancellationToken) : "<no result file>";
// Assert the artifact first: what the child itself recorded about the run.
outcome.ShouldBe(
$"FAULTED:{nameof(ScriptStackExhaustedException)}",
$"child exit {exitCode}; stderr head: {Head(stderr)}; stderr tail: {Tail(stderr)}; stdout tail: {Tail(stdout)}");
exitCode.ShouldBe(0, $"the process must survive the script; stderr tail: {Tail(stderr)}");
}
finally
{
File.Delete(resultFile);
}
}
/// <summary>
/// The child half. Skipped in a normal run; it executes only inside a process launched by
/// <see cref="RunChildAsync"/>, where it compiles one scenario and runs it the way the production seams do
/// (<see cref="ScriptRunGuard"/> on the thread pool), then records how the run ended.
/// </summary>
[Fact]
public async Task Child_runs_one_recursive_script_through_the_production_run_path()
{
var scenario = Environment.GetEnvironmentVariable(ScenarioVariable);
var resultFile = Environment.GetEnvironmentVariable(ResultFileVariable);
if (string.IsNullOrEmpty(scenario) || string.IsNullOrEmpty(resultFile))
Assert.Skip("Child half of ScriptRecursionContainmentTests; runs only in the process that test launches.");
var source = Scenarios[scenario];
using var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(source);
var guard = new ScriptRunGuard();
string outcome;
try
{
var value = await guard.RunAsync(scenario, source, evaluator, new FakeScriptContext(), TimeSpan.FromSeconds(60), TestContext.Current.CancellationToken);
outcome = $"RETURNED:{value}";
}
catch (Exception ex)
{
outcome = $"FAULTED:{ex.GetType().Name}";
}
await File.WriteAllTextAsync(resultFile, outcome, TestContext.Current.CancellationToken);
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunChildAsync(string scenario, string resultFile)
{
var assembly = typeof(ScriptRecursionContainmentTests).Assembly.Location;
var childTest = $"{typeof(ScriptRecursionContainmentTests).FullName}.{nameof(Child_runs_one_recursive_script_through_the_production_run_path)}";
var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
psi.ArgumentList.Add("exec");
psi.ArgumentList.Add(assembly);
psi.ArgumentList.Add("-method");
psi.ArgumentList.Add(childTest);
psi.Environment[ScenarioVariable] = scenario;
psi.Environment[ResultFileVariable] = resultFile;
// A crash dump of a deliberate stack overflow is noise, not evidence.
psi.Environment["DOTNET_DbgEnableMiniDump"] = "0";
using var process = Process.Start(psi) ?? throw new InvalidOperationException("could not start the child test process");
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException($"child for '{scenario}' did not exit within 2 minutes");
}
return (process.ExitCode, await stdout, await stderr);
}
private static string Head(string text) => text.Length <= 200 ? text : text[..200];
private static string Tail(string text) => text.Length <= 600 ? text : text[^600..];
}
@@ -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);
}
}