d2cc071517
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
68 lines
3.2 KiB
C#
68 lines
3.2 KiB
C#
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);
|
|
}
|
|
}
|