diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs index 80139a65..f1304ac4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs @@ -11,8 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// /// Thread-model note: the indexer get/set block synchronously /// on the Instance Actor Ask (and, for data-connected attributes, the DCL -/// round-trip). Runtime helper continuations use ConfigureAwait(false), so they -/// can complete even when every dedicated worker is blocked in an indexer. +/// round-trip). The runtime helper awaits in and +/// use ConfigureAwait(false), so their continuations run on +/// the shared thread pool and can complete even when every dedicated worker is blocked in an +/// indexer. A script that itself blocks on a helper task (.Result/.Wait()) still +/// holds its worker until the callee finishes, but no longer deadlocks the scheduler against it. /// Script bodies execute on the dedicated /// threads, not the /// shared — so a blocked accessor diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index e01fb170..3f0c6366 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1150,7 +1150,7 @@ public class ScriptRuntimeContext Trace: _isTraced); // Ask the Instance Actor, which routes to the appropriate Script Actor - var result = await _instanceActor.Ask(request, _askTimeout); + var result = await _instanceActor.Ask(request, _askTimeout).ConfigureAwait(false); if (!result.Success) { @@ -1285,7 +1285,7 @@ public class ScriptRuntimeContext Guid.NewGuid().ToString(), _instanceName, DateTimeOffset.UtcNow); var response = await _instanceActor.Ask( - request, _askTimeout, cancellationToken); + request, _askTimeout, cancellationToken).ConfigureAwait(false); return response.Alarms.Select(ToScriptAlarm).ToList(); } @@ -1294,7 +1294,7 @@ public class ScriptRuntimeContext /// Cancels the wait for the actor's reply. /// The MES-reportable subset of the instance's current alarm conditions. internal async Task> GetMesReportableAlarmsAsync(CancellationToken cancellationToken = default) - => (await GetAlarmsAsync(cancellationToken)).Where(a => a.IsMesReportable).ToList(); + => (await GetAlarmsAsync(cancellationToken).ConfigureAwait(false)).Where(a => a.IsMesReportable).ToList(); /// /// Projects a retained onto the flat, script-facing @@ -1407,7 +1407,7 @@ public class ScriptRuntimeContext var childContext = _context.CreateChildContextForSharedScript(nextDepth); return await _library.ExecuteAsync( - scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken); + scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken).ConfigureAwait(false); } } @@ -1564,7 +1564,7 @@ public class ScriptRuntimeContext Exception? thrown = null; try { - result = await _client.CallAsync(systemName, methodName, parameters, cancellationToken); + result = await _client.CallAsync(systemName, methodName, parameters, cancellationToken).ConfigureAwait(false); return result; } catch (Exception ex) @@ -2269,7 +2269,7 @@ public class ScriptRuntimeContext if (_gateway == null) throw new InvalidOperationException("Database gateway not available"); - var inner = await _gateway.GetConnectionAsync(name, cancellationToken); + var inner = await _gateway.GetConnectionAsync(name, cancellationToken).ConfigureAwait(false); // Wrap in an auditing decorator so // every script-initiated Execute* / ExecuteReader on the returned @@ -2735,7 +2735,7 @@ public class ScriptRuntimeContext try { response = await _siteCommunicationActor - .Ask(query, _askTimeout); + .Ask(query, _askTimeout).ConfigureAwait(false); } catch (Exception ex) { @@ -2760,7 +2760,7 @@ public class ScriptRuntimeContext // genuinely unknown (never sent, or already forwarded and central lost it). if (_storeAndForward != null) { - var buffered = await _storeAndForward.GetMessageByIdAsync(notificationId); + var buffered = await _storeAndForward.GetMessageByIdAsync(notificationId).ConfigureAwait(false); if (buffered != null) { return new NotificationDeliveryStatus( @@ -2943,7 +2943,7 @@ public class ScriptRuntimeContext // thread: buffer due-immediately and kick the sweep. Send returns // in milliseconds whether central is up or down. deferToSweep: true, - messageId: notificationId); + messageId: notificationId).ConfigureAwait(false); _logger.LogDebug( "Notify enqueued notification {NotificationId} to list '{List}' for central delivery", diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SharedScriptLibrary.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SharedScriptLibrary.cs index 684986be..a8d0e9d7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SharedScriptLibrary.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SharedScriptLibrary.cs @@ -102,7 +102,7 @@ public class SharedScriptLibrary CancellationToken = cancellationToken }; - var state = await script.RunAsync(globals, cancellationToken); + var state = await script.RunAsync(globals, cancellationToken).ConfigureAwait(false); return state.ReturnValue; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs index 62244f2b..d96bc955 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs @@ -254,4 +254,24 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.Single(await pending); } + + /// + /// The helper's continuation must not capture the script scheduler: a script that blocks its + /// only worker on the task (.Wait()) still completes (review 2026-10-21 01/SR-U2). + /// + [Fact] + public async Task CurrentAsync_ContinuationDoesNotNeedTheScriptScheduler_BlockingWaitCompletes() + { + using var scheduler = new ScriptExecutionScheduler(1); + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var run = Task.Factory.StartNew( + () => ctx.Alarms.CurrentAsync().Wait(TimeSpan.FromSeconds(10)), + CancellationToken.None, TaskCreationOptions.None, scheduler); + + RespondWith(probe); + + Assert.True(await run.WaitAsync(TimeSpan.FromSeconds(15)), "CurrentAsync continuation was queued back onto the blocked script scheduler"); + } }