diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index 5959e204..30f1a443 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -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 diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index 0bb0fc75..af2f0fca 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -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. diff --git a/docs/plans/2026-11-09-out-of-process-script-runner-design.md b/docs/plans/2026-11-09-out-of-process-script-runner-design.md index 7f27f7db..9bccf3c4 100644 --- a/docs/plans/2026-11-09-out-of-process-script-runner-design.md +++ b/docs/plans/2026-11-09-out-of-process-script-runner-design.md @@ -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` | diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptStackGuard.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptStackGuard.cs new file mode 100644 index 00000000..b677ea4c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptStackGuard.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Stack-depth guard injected into compiled user scripts (lmxopcua#779). ScriptEvaluator.Compile inserts a +/// call to 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. +/// +/// +/// +/// Why. A cannot be caught in .NET: the runtime terminates the +/// process. One script with unbounded recursion (a local function, a self-referencing Func<>, an +/// anonymous self-application) would take down the whole server — every OPC UA session, every driver, the +/// node's Akka membership. reports false while +/// there is still a safety margin of stack left, so a catchable exception is thrown instead: the +/// script fails like any other throwing script (Bad quality, script log) and the process lives. +/// +/// +/// Limits. 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). +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ScriptStackGuard +{ + /// Throws when the current thread is close to the end of its + /// stack. Called by compiled scripts; not part of the script authoring surface. + /// The remaining stack is below the runtime's safety margin. + public static void Check() + { + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + throw new ScriptStackExhaustedException(); + } +} + +/// +/// A script recursed (or nested calls) deep enough to exhaust its thread's stack. Thrown by +/// before the runtime would raise an uncatchable +/// ; the evaluating seam reports it like any other script exception. +/// +public sealed class ScriptStackExhaustedException : Exception +{ + /// Initializes a new instance of the class. + 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.") + { + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs index a33fba6f..5f5b23d2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs @@ -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 ; (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 on entry to every script-declared body, so unbounded +/// recursion fails the script instead of overflowing the stack and terminating the process +/// (, lmxopcua#779). /// /// /// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag @@ -120,6 +123,14 @@ public sealed class ScriptEvaluator : 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); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptStackGuardInjector.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptStackGuardInjector.cs new file mode 100644 index 00000000..fbd43b5d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptStackGuardInjector.cs @@ -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; + +/// +/// Rewrites the synthesized script wrapper so that the script body and every lambda, anonymous method and local +/// function in it starts with ScriptStackGuard.Check(); (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 +/// instead of an uncatchable +/// that would terminate the server. +/// +/// +/// +/// Why a runtime check and not an analyzer rule rejecting recursion. Recursion cannot be decided from +/// syntax: Func<object,int> g = s => ((Func<object,int>)s)(s); g(g); 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. +/// +/// +/// Semantics preserved. An expression-bodied lambda or local function becomes a block — { Check(); +/// return e; }, or { Check(); e; } 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 (Expression<T>) 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 System.Threading.Tasks, so a script cannot +/// produce one that compiles). +/// +/// +/// Runs after , so the analyzer only ever sees the user's code; the +/// inserted call names a type in Core.Scripting.Abstractions, which the sandbox already references. +/// +/// +internal static class ScriptStackGuardInjector +{ + private const string CheckCall = "global::ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptStackGuard.Check()"; + + /// Returns with a stack check at the entry of every script-declared body. + /// The compiled (error-free, analyzer-clean) wrapper compilation. + /// The wrapper syntax tree inside . + /// The rewritten tree, with the original parse options. + 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)!); + + /// The node in the bound tree (semantic queries must use it). + /// The same node with its descendants already rewritten. + 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"; + } + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptRecursionContainmentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptRecursionContainmentTests.cs new file mode 100644 index 00000000..1098eccd --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptRecursionContainmentTests.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// lmxopcua#779 — can unbounded recursion in a user script take the whole host process down? A +/// cannot be caught in .NET, so the only safe way to ask is in a +/// separate process: each case re-launches this test assembly (an xunit v3 executable) to run exactly one +/// case, and inspects how that +/// process ended. The test runner itself never executes a recursive script. +/// +/// +/// 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 +/// , which the run path reports as an ordinary script fault, and the +/// process lives on. +/// +public sealed class ScriptRecursionContainmentTests +{ + private const string ScenarioVariable = "OTOPCUA_RECURSION_CHILD_SCENARIO"; + private const string ResultFileVariable = "OTOPCUA_RECURSION_CHILD_RESULT"; + + /// 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. + public static readonly Dictionary 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 f = null!; f = n => f(n + 1) + 1; return f(0);", + ["anonymous-self-application"] = + "System.Func g = s => ((System.Func)s)(s) + 1; return g(g);", + ["recursion-through-linq"] = + "System.Func 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 a = null!; System.Func h = n => { a(n); return n; }; a = n => h(n + 1); a(0); return 0;", + ["anonymous-method"] = + "System.Func f = null!; f = delegate (int n) { return f(n + 1) + 1; }; return f(0);", + }; + + public static TheoryData 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) : ""; + + // 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); + } + } + + /// + /// The child half. Skipped in a normal run; it executes only inside a process launched by + /// , where it compiles one scenario and runs it the way the production seams do + /// ( on the thread pool), then records how the run ended. + /// + [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.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..]; +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptStackGuardInjectionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptStackGuardInjectionTests.cs new file mode 100644 index 00000000..2251fbfe --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptStackGuardInjectionTests.cs @@ -0,0 +1,52 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// lmxopcua#779 — the stack-guard rewrite () 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 +/// ; this class runs only scripts that terminate. +/// +public sealed class ScriptStackGuardInjectionTests +{ + private static object? Run(string source) + { + using var evaluator = ScriptEvaluator.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 f = null!; f = n => n <= 0 ? 0 : f(n - 1) + 1; return f(500);", 500)] + [InlineData("System.Func 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(); new System.Collections.Generic.List { 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 Make(int k) => x => x + k; return Make(3)(4);", 7)] + // Static lambda and a lambda capturing ctx. + [InlineData("System.Func f = static x => x - 1; return f(9);", 8)] + [InlineData("System.Func 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); + + /// 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. + [Fact] + public void An_expression_tree_lambda_is_not_rewritten() + { + Run("System.Linq.Expressions.Expression> e = x => x + 1; return (int)e.Body.NodeType;") + .ShouldBe((int)System.Linq.Expressions.ExpressionType.Add); + } +}