Merge b647-ci: Gitea-compatible artifact upload + deterministic CI-flaky tests (#606)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:45:50 -04:00
36 changed files with 476 additions and 132 deletions
@@ -769,8 +769,11 @@ public sealed class ScriptedAlarmEngineTests
// Drive to Active so Acknowledge has something to ack.
up.Push("Temp", 150);
// Use the same WaitForAsync that other tests use — synchronously
// here since this is a non-async test.
for (var i = 0; i < 80 && eng.GetState("HighTemp")!.Active != AlarmActiveState.Active; i++)
// here since this is a non-async test. A 15 s presence ceiling, not 2 s: this thread blocks a
// pool thread while the drain that activates the alarm needs another, and under thread-pool
// starvation (DOTNET_PROCESSOR_COUNT=1, 8 parallel copies) 2 s failed 5 runs in 20 (#606).
var activeBy = DateTime.UtcNow.AddSeconds(15);
while (DateTime.UtcNow < activeBy && eng.GetState("HighTemp")!.Active != AlarmActiveState.Active)
Thread.Sleep(25);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
@@ -1183,6 +1186,23 @@ public sealed class ScriptedAlarmEngineTests
throw new TimeoutException("Condition did not become true in time");
}
/// <summary>
/// Waits for a blocked-Activated-save scenario to finish its follow-up pass: the Cleared transition
/// has been persisted (the second SaveAsync) AND applied in memory.
/// </summary>
/// <remarks>
/// Waiting for <c>Active == Inactive</c> alone is ambiguous here — Inactive is also the state the
/// alarm STARTED in, and the engine persists BEFORE it updates memory, so in the instant after the
/// gate is released the in-memory state still reads the pre-activation Inactive. The wait then
/// returned before the follow-up pass ran and the evaluation count read 1 (#606, a Gitea CI failure
/// in 137 ms). <see cref="CountingAlarmStateStore.SaveCount"/> counts at SaveAsync entry, so
/// "two saves started AND Inactive in memory" is only true after the Cleared update.
/// </remarks>
private static Task WaitForClearedFollowUpAsync(ScriptedAlarmEngine eng, CountingAlarmStateStore store) =>
WaitForAsync(
() => store.SaveCount >= 2 && eng.GetState("A")!.Active == AlarmActiveState.Inactive,
timeoutMs: 3000);
// -------------------------------------------------------------------------
// Test helpers
// -------------------------------------------------------------------------
@@ -1486,7 +1506,7 @@ public sealed class ScriptedAlarmEngineTests
up.Push("Temp", 50);
saveGate.SetResult();
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive, timeoutMs: 3000);
await WaitForClearedFollowUpAsync(eng, store);
probe.Count.ShouldBeLessThanOrEqualTo(2,
"11 pushes over one blocked evaluation must produce the in-flight evaluation plus at " +
@@ -1529,7 +1549,9 @@ public sealed class ScriptedAlarmEngineTests
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// LoadAsync evaluates AND saves once (startup recovery) — count both from here.
probe.Reset();
store.ResetCount();
var saveGate = new TaskCompletionSource();
store.BlockNextSave = saveGate;
@@ -1542,7 +1564,7 @@ public sealed class ScriptedAlarmEngineTests
up.Push("Temp", 40);
saveGate.SetResult();
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive, timeoutMs: 3000);
await WaitForClearedFollowUpAsync(eng, store);
probe.Count.ShouldBe(2, "the in-flight pass plus exactly one follow-up pass for the late push");
}
@@ -13,6 +13,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
[Trait("Category", "Unit")]
public sealed class TimedScriptEvaluatorTests
{
/// <summary>
/// A CPU-bound script that outlives every timeout in this file but still ENDS.
/// </summary>
/// <remarks>
/// A timed-out script is abandoned, not stopped — its pool thread keeps running (the documented
/// <see cref="TimedScriptEvaluator{TContext, TResult}"/> leak). The previous body,
/// <c>long acc = 0; while (true) { acc++; if (acc &lt; 0) break; }</c>, needs 2^63 increments to
/// overflow, so each of the three tests using it pinned one core at 100 % for the rest of the test
/// process — CPU the parallel suites on a small CI runner were starving for (#606). A 5 s wall-clock
/// spin is still one to two orders of magnitude past the 30–80 ms timeouts/cancels asserted here, so
/// the script cannot finish first even under heavy load, and the abandoned thread exits on its own.
/// <c>System.DateTime</c> is outside the sandbox deny-list; Thread/Environment/Diagnostics are not.
/// </remarks>
private const string BoundedSpinScript =
"""
var end = System.DateTime.UtcNow.AddSeconds(5);
while (System.DateTime.UtcNow < end) { }
return 1;
""";
/// <summary>Verifies that fast scripts complete under timeout and return value.</summary>
[Fact]
public async Task Fast_script_completes_under_timeout_and_returns_value()
@@ -33,13 +53,8 @@ public sealed class TimedScriptEvaluatorTests
{
// Scripts can't reach the sandbox-denied process surface (System.Threading.Thread
// and System.Environment are both denied — Core.Scripting-001). A tight CPU loop
// that never returns exceeds any short timeout without touching a forbidden type.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
long acc = 0;
while (true) { acc++; if (acc < 0) break; }
return 1;
""");
// that outlives the timeout exceeds it without touching a forbidden type.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(BoundedSpinScript);
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromMilliseconds(50));
@@ -56,12 +71,7 @@ public sealed class TimedScriptEvaluatorTests
// A CPU-bound script that would otherwise timeout; external ct fires first.
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
// paths aren't misclassified as timeouts.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
long acc = 0;
while (true) { acc++; if (acc < 0) break; }
return 1;
""");
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(BoundedSpinScript);
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromSeconds(5));
@@ -127,12 +137,7 @@ public sealed class TimedScriptEvaluatorTests
[Fact]
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
{
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
long acc = 0;
while (true) { acc++; if (acc < 0) break; }
return 1;
""");
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(BoundedSpinScript);
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromMilliseconds(30));