162 lines
6.9 KiB
C#
162 lines
6.9 KiB
C#
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// The dedicated script-execution scheduler must run script bodies on
|
|
/// its own dedicated threads, not on the shared .NET thread pool, so blocking script
|
|
/// I/O cannot starve the global pool.
|
|
/// </summary>
|
|
public class ScriptExecutionSchedulerTests
|
|
{
|
|
/// <summary>Verifies that the scheduler does not inline work on another scheduler's worker thread.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Scheduler_DoesNotInlineOnAnotherSchedulersWorker()
|
|
{
|
|
using var first = new ScriptExecutionScheduler(1);
|
|
using var second = new ScriptExecutionScheduler(1);
|
|
await Task.Factory.StartNew(() =>
|
|
{
|
|
var firstThread = Environment.CurrentManagedThreadId;
|
|
var nested = new Task<int>(() => Environment.CurrentManagedThreadId);
|
|
nested.RunSynchronously(second);
|
|
Assert.NotEqual(firstThread, nested.Result);
|
|
}, CancellationToken.None, TaskCreationOptions.None, first);
|
|
}
|
|
|
|
/// <summary>Verifies that the scheduler runs work off the shared thread pool.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Scheduler_RunsWork_OffTheThreadPool()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(2);
|
|
|
|
bool wasThreadPoolThread = true;
|
|
string? threadName = null;
|
|
|
|
await Task.Factory.StartNew(() =>
|
|
{
|
|
wasThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
|
|
threadName = Thread.CurrentThread.Name;
|
|
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler);
|
|
|
|
Assert.False(wasThreadPoolThread,
|
|
"Script work must not run on a shared thread-pool thread.");
|
|
Assert.StartsWith("script-execution-", threadName);
|
|
}
|
|
|
|
/// <summary>Verifies that the scheduler respects the configured thread count.</summary>
|
|
[Fact]
|
|
public void Scheduler_RespectsConfiguredThreadCount()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(4);
|
|
Assert.Equal(4, scheduler.MaximumConcurrencyLevel);
|
|
}
|
|
|
|
/// <summary>Verifies that the shared scheduler returns the same instance for the same options.</summary>
|
|
[Fact]
|
|
public void Scheduler_Shared_ReturnsSameInstanceForOptions()
|
|
{
|
|
var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 3 };
|
|
var a = ScriptExecutionScheduler.Shared(options);
|
|
var b = ScriptExecutionScheduler.Shared(options);
|
|
Assert.Same(a, b);
|
|
}
|
|
|
|
// IsDisposed backs the Shared() recreate guard so a disposed scheduler
|
|
// can never be handed back and silently poison every later execution. Verified on a
|
|
// local instance — deliberately NOT by disposing the process-wide singleton, which
|
|
// would strand any script another test class queued on it (the very static hazard
|
|
// this test guards against).
|
|
/// <summary>Verifies that <c>IsDisposed</c> flips to true when the scheduler is disposed.</summary>
|
|
[Fact]
|
|
public void IsDisposed_FlipsOnDispose()
|
|
{
|
|
var scheduler = new ScriptExecutionScheduler(1);
|
|
Assert.False(scheduler.IsDisposed);
|
|
|
|
scheduler.Dispose();
|
|
Assert.True(scheduler.IsDisposed);
|
|
|
|
scheduler.Dispose(); // idempotent — stays disposed, does not throw
|
|
Assert.True(scheduler.IsDisposed);
|
|
}
|
|
|
|
// SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck /
|
|
// saturated script-execution pool is visible to operators via site health.
|
|
/// <summary>Verifies that the gauges reflect the busy thread count and the queue depth.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Gauges_ReflectBusyThreadAndQueueDepth()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(1);
|
|
using var gate = new ManualResetEventSlim(false);
|
|
|
|
var blocking = Task.Factory.StartNew(() => gate.Wait(),
|
|
CancellationToken.None, TaskCreationOptions.None, scheduler);
|
|
var queued = Task.Factory.StartNew(() => { },
|
|
CancellationToken.None, TaskCreationOptions.None, scheduler);
|
|
|
|
await WaitUntilAsync(() => scheduler.BusyThreadCount == 1);
|
|
Assert.Equal(1, scheduler.QueueDepth); // second task waiting
|
|
// A busy thread reports a non-null age. Not asserting a strictly-positive
|
|
// value: Environment.TickCount64 has ~15 ms granularity, so under load the
|
|
// busy-timestamp and this read can fall in the same tick (age == Zero).
|
|
Assert.NotNull(scheduler.OldestBusyAge);
|
|
|
|
gate.Set();
|
|
await Task.WhenAll(blocking, queued);
|
|
await WaitUntilAsync(() => scheduler.BusyThreadCount == 0);
|
|
Assert.Null(scheduler.OldestBusyAge);
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
|
{
|
|
for (var i = 0; i < 100 && !condition(); i++)
|
|
await Task.Delay(50);
|
|
Assert.True(condition(), "condition not met within timeout");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Repeatedly wedging + detaching a worker must reuse the vacated slot rather than grow the
|
|
/// slot table without bound (review 2026-10-21 01/SR-M1).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DetachedWorkerSlot_IsReused_SlotTableStaysBounded()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(1);
|
|
|
|
for (var round = 0; round < 6; round++)
|
|
{
|
|
var gate = new ManualResetEventSlim(false);
|
|
var started = new TaskCompletionSource<(int Slot, long Stamp)>();
|
|
var wedged = Task.Factory.StartNew(() =>
|
|
{
|
|
var slot = ScriptExecutionScheduler.CurrentWorkerSlot!.Value;
|
|
started.SetResult((slot, scheduler.CurrentRunStamp(slot)));
|
|
gate.Wait();
|
|
}, CancellationToken.None, TaskCreationOptions.None, scheduler);
|
|
|
|
var (s, stamp) = await started.Task;
|
|
Assert.Equal(WorkerDetachOutcome.Detached, scheduler.TryDetachWorker(s, stamp));
|
|
gate.Set();
|
|
await wedged;
|
|
|
|
// Wait for the detached worker to exit and hand its slot back.
|
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
|
while (scheduler.DetachedThreadCount != 0 && DateTime.UtcNow < deadline)
|
|
await Task.Delay(10);
|
|
Assert.Equal(0, scheduler.DetachedThreadCount);
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
// 1 live + at most 1 free/in-flight detached slot, never 1 + 6.
|
|
Assert.True(scheduler.SlotCount <= 2, $"slot table grew to {scheduler.SlotCount}");
|
|
// The pool still works afterwards.
|
|
var ran = await Task.Factory.StartNew(() => 42, CancellationToken.None, TaskCreationOptions.None, scheduler);
|
|
Assert.Equal(42, ran);
|
|
}
|
|
}
|