feat(scripting): expose the run token to scripts as ctx.CancellationToken
ScriptContext.CancellationToken is backed by an AsyncLocal that ScriptEvaluator.RunAsync scopes around the compiled delegate, so it is exactly the token ScriptRunGuard cancels on timeout. A polling script can now end its own abandoned run and free the slot. AsyncLocal rather than a field because the scripted-alarm engine reuses one context per alarm: an orphan keeps seeing its own cancelled token. The scoping entry point is internal (IVT to Core.Scripting) so a script cannot replace its own token. Editor parity: ScriptAnalysisService dot-completion now walks the receiver's base chain (instance members only, stopping before System.Object). Before this, a member declared only on ScriptContext compiled and published but never completed. DiagnoseTests pins the member as allow-listed; CompletionTests pins the completion. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -80,6 +80,49 @@ public abstract class ScriptContext
|
||||
/// </summary>
|
||||
public abstract ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ctx.CancellationToken.IsCancellationRequested</c> (or call
|
||||
/// <c>ThrowIfCancellationRequested()</c>) 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. <see cref="CancellationToken.None"/> outside a run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scoped per run through an <see cref="AsyncLocal{T}"/>, 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.
|
||||
/// </remarks>
|
||||
public CancellationToken CancellationToken => s_runToken.Value;
|
||||
|
||||
private static readonly AsyncLocal<CancellationToken> s_runToken = new();
|
||||
|
||||
/// <summary>
|
||||
/// Makes <paramref name="token"/> the script-visible <see cref="CancellationToken"/> on the calling
|
||||
/// execution context until the returned scope is disposed. Called by <c>ScriptEvaluator.RunAsync</c>
|
||||
/// around the compiled delegate.
|
||||
/// </summary>
|
||||
/// <param name="token">The run token.</param>
|
||||
/// <returns>A scope that restores the previous token.</returns>
|
||||
internal static RunTokenScope EnterRun(CancellationToken token)
|
||||
{
|
||||
var previous = s_runToken.Value;
|
||||
s_runToken.Value = token;
|
||||
return new RunTokenScope(previous);
|
||||
}
|
||||
|
||||
/// <summary>Restores the token that was current before <see cref="EnterRun"/>.</summary>
|
||||
internal readonly struct RunTokenScope : IDisposable
|
||||
{
|
||||
private readonly CancellationToken _previous;
|
||||
|
||||
/// <summary>Captures the token to restore.</summary>
|
||||
/// <param name="previous">The token current before the run.</param>
|
||||
public RunTokenScope(CancellationToken previous) => _previous = previous;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => s_runToken.Value = _previous;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
|
||||
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
|
||||
|
||||
+6
@@ -16,4 +16,10 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- ScriptEvaluator scopes the script-visible run token (ScriptContext.EnterRun). Internal so a script
|
||||
cannot replace its own token. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -185,7 +185,13 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
// TResult directly). We surface a Task<TResult> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ISymbol>();
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ctx.CancellationToken</c> — the run token, visible to scripts. <see cref="ScriptRunGuard"/> already
|
||||
/// cancels the token it hands <see cref="ScriptEvaluator{TContext, TResult}.RunAsync"/> 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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptCancellationTokenTests
|
||||
{
|
||||
/// <summary>Spins until cancelled, but ends by itself after 10 s so a broken implementation cannot pin a
|
||||
/// pool thread past the test.</summary>
|
||||
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<FakeScriptContext, bool>.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<FakeScriptContext, int>.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<FakeScriptContext, int>.Compile(CooperativeSpin);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await Should.ThrowAsync<ScriptTimeoutException>(
|
||||
() => 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<FakeScriptContext, int>.Compile("return 5;");
|
||||
(await guard.RunAsync("k", CooperativeSpin, fast, new FakeScriptContext(), TimeSpan.FromSeconds(5), ct))
|
||||
.ShouldBe(5);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user