using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// 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. /// public class ScriptExecutionSchedulerTests { /// Verifies that the scheduler does not inline work on another scheduler's worker thread. /// A task that represents the asynchronous operation. [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(() => Environment.CurrentManagedThreadId); nested.RunSynchronously(second); Assert.NotEqual(firstThread, nested.Result); }, CancellationToken.None, TaskCreationOptions.None, first); } /// Verifies that the scheduler runs work off the shared thread pool. /// A task that represents the asynchronous operation. [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); } /// Verifies that the scheduler respects the configured thread count. [Fact] public void Scheduler_RespectsConfiguredThreadCount() { using var scheduler = new ScriptExecutionScheduler(4); Assert.Equal(4, scheduler.MaximumConcurrencyLevel); } /// Verifies that the shared scheduler returns the same instance for the same options. [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). /// Verifies that IsDisposed flips to true when the scheduler is disposed. [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. /// Verifies that the gauges reflect the busy thread count and the queue depth. /// A task that represents the asynchronous operation. [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 condition) { for (var i = 0; i < 100 && !condition(); i++) await Task.Delay(50); Assert.True(condition(), "condition not met within timeout"); } /// /// 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). /// [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); } }