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:
@@ -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 < 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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user