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