Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs
T
Joseph Doherty d2cc071517 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
2026-10-22 14:31:03 -04:00

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);
}
}