diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index eb6985e2..b6472f00 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -198,6 +198,13 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`:
2 s default) quarantines immediately, through the same path (Error log, metric
`reason=stuck`, Bad). A short overrun (a young orphan) stays a plain refusal and
the script resumes once the orphan completes.
+- **Cooperative cancellation (`ctx.CancellationToken`).** The guard cancels the run
+ token when a run times out, and scripts see it as `ctx.CancellationToken`. A
+ long-running loop that polls `ctx.CancellationToken.IsCancellationRequested` (or calls
+ `ctx.CancellationToken.ThrowIfCancellationRequested()`) ends its own abandoned run
+ and frees the slot, instead of holding a pool thread until it returns. A script that
+ ignores the token is still an orphan. The editor completes the member, and
+ `CancellationToken` is on the sandbox allow-list.
- **Allocation budget.** A single run allocating more than
`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
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs
index 6a73c622..e293ff98 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs
@@ -80,6 +80,49 @@ public abstract class ScriptContext
///
public abstract ILogger Logger { get; }
+ ///
+ /// The current run's cancellation token. The run guard cancels it when the run exceeds its wall-clock
+ /// budget, so a long-running script can poll ctx.CancellationToken.IsCancellationRequested (or call
+ /// ThrowIfCancellationRequested()) and end its own abandoned run instead of holding a pool thread
+ /// until it returns. Cooperative only: a script that never looks at it still runs to completion as an
+ /// orphan. outside a run.
+ ///
+ ///
+ /// Scoped per run through an , not stored on the context, because a context
+ /// object can be reused across runs (the scripted-alarm engine keeps one per alarm). An abandoned orphan
+ /// therefore keeps seeing its own cancelled token even after a newer run starts on the same context.
+ ///
+ public CancellationToken CancellationToken => s_runToken.Value;
+
+ private static readonly AsyncLocal s_runToken = new();
+
+ ///
+ /// Makes the script-visible on the calling
+ /// execution context until the returned scope is disposed. Called by ScriptEvaluator.RunAsync
+ /// around the compiled delegate.
+ ///
+ /// The run token.
+ /// A scope that restores the previous token.
+ internal static RunTokenScope EnterRun(CancellationToken token)
+ {
+ var previous = s_runToken.Value;
+ s_runToken.Value = token;
+ return new RunTokenScope(previous);
+ }
+
+ /// Restores the token that was current before .
+ internal readonly struct RunTokenScope : IDisposable
+ {
+ private readonly CancellationToken _previous;
+
+ /// Captures the token to restore.
+ /// The token current before the run.
+ public RunTokenScope(CancellationToken previous) => _previous = previous;
+
+ ///
+ public void Dispose() => s_runToken.Value = _previous;
+ }
+
///
/// Deadband helper — returns true when differs
/// from by more than .
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj
index f9305356..0f07e95c 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj
@@ -16,4 +16,10 @@
+
+
+
+
+
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 c7ac8280..a33fba6f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
@@ -185,7 +185,13 @@ public sealed class ScriptEvaluator : IDisposable
// TResult directly). We surface a Task only to keep the existing
// RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in
// Task.Run so a long-running script still honours its wall-clock budget.
- var result = _func(globals);
+ // The run token is the script-visible ctx.CancellationToken for exactly this call. ScriptRunGuard passes
+ // the token it cancels on timeout, so a polling script can end its own abandoned run.
+ TResult result;
+ using (ScriptContext.EnterRun(ct))
+ {
+ result = _func(globals);
+ }
return Task.FromResult(result);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
index ba69b0da..9df80b09 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -268,7 +268,14 @@ public sealed class ScriptAnalysisService
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
- return type.GetMembers()
+ // The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
+ // ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on
+ // ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics
+ // (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver.
+ var inherited = new List();
+ for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType)
+ inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic));
+ return type.GetMembers().Concat(inherited)
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
// NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable.
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs
new file mode 100644
index 00000000..a465d2d4
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs
@@ -0,0 +1,67 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
+
+///
+/// ctx.CancellationToken — the run token, visible to scripts. already
+/// cancels the token it hands when a run times out;
+/// a script that polls it can now end its own abandoned run instead of burning a pool thread until it
+/// returns. Cooperative only: a script that ignores it is still an orphan.
+///
+[Trait("Category", "Unit")]
+public sealed class ScriptCancellationTokenTests
+{
+ /// Spins until cancelled, but ends by itself after 10 s so a broken implementation cannot pin a
+ /// pool thread past the test.
+ private const string CooperativeSpin =
+ """
+ var end = System.DateTime.UtcNow.AddSeconds(10);
+ while (!ctx.CancellationToken.IsCancellationRequested && System.DateTime.UtcNow < end) { }
+ return ctx.CancellationToken.IsCancellationRequested ? -1 : 1;
+ """;
+
+ [Fact]
+ public async Task A_script_sees_an_uncancelled_token_on_a_normal_run()
+ {
+ using var eval = ScriptEvaluator.Compile(
+ "return ctx.CancellationToken.CanBeCanceled && !ctx.CancellationToken.IsCancellationRequested;");
+ using var cts = new CancellationTokenSource();
+
+ (await eval.RunAsync(new FakeScriptContext(), cts.Token)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Outside_a_run_the_token_is_None()
+ {
+ using var eval = ScriptEvaluator.Compile("return 1;");
+ var ctx = new FakeScriptContext();
+ (await eval.RunAsync(ctx)).ShouldBe(1);
+
+ ctx.CancellationToken.ShouldBe(CancellationToken.None, "the token is scoped to the run, not left on the context");
+ }
+
+ [Fact]
+ public async Task A_timed_out_run_whose_script_polls_the_token_ends_its_own_orphan()
+ {
+ var guard = new ScriptRunGuard();
+ using var eval = ScriptEvaluator.Compile(CooperativeSpin);
+ var ct = TestContext.Current.CancellationToken;
+
+ await Should.ThrowAsync(
+ () => guard.RunAsync("k", CooperativeSpin, eval, new FakeScriptContext(), TimeSpan.FromMilliseconds(100), ct));
+
+ // The guard cancelled the run token on timeout; the script saw it and returned, which frees the slot well
+ // inside its own 10 s fallback. Without ctx.CancellationToken the orphan would hold it for the full 10 s.
+ var deadline = DateTime.UtcNow.AddSeconds(3);
+ while (guard.InFlightCount != 0 && DateTime.UtcNow < deadline)
+ await Task.Delay(20, ct);
+ guard.InFlightCount.ShouldBe(0, "the cooperative script ended its abandoned run");
+
+ // Positive control: the slot is free, so the next run is dispatched rather than refused. Same source
+ // identity on purpose: a different one would reset the key and dispatch even behind a live orphan.
+ using var fast = ScriptEvaluator.Compile("return 5;");
+ (await guard.RunAsync("k", CooperativeSpin, fast, new FakeScriptContext(), TimeSpan.FromSeconds(5), ct))
+ .ShouldBe(5);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
index 63b319da..be424a2a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
@@ -18,6 +18,7 @@ public sealed class CompletionTests
labels.ShouldContain("SetVirtualTag");
labels.ShouldContain("Now");
labels.ShouldContain("Logger");
+ labels.ShouldContain("CancellationToken");
}
[Fact] public async Task Scope_completion_includes_the_ctx_local()
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
index 94784a45..27c8679e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
@@ -48,6 +48,7 @@ public sealed class DiagnoseTests
[InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")]
[InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")]
[InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")]
+ [InlineData("""ctx.CancellationToken.ThrowIfCancellationRequested(); return ctx.CancellationToken.IsCancellationRequested ? 1 : 0;""")]
public void Allow_listed_forms_are_not_flagged_in_the_editor(string code)
=> Diag(code).ShouldBeEmpty();