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..]; }