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