fix(site-runtime): ConfigureAwait(false) on remaining script helper awaits (review 2026-10-21 01/SR-U2)

This commit is contained in:
Joseph Doherty
2026-10-22 08:31:56 -04:00
parent 4ff26a0614
commit 010d2ea438
4 changed files with 35 additions and 12 deletions
@@ -11,8 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <para>
/// 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 <see cref="ScriptRuntimeContext"/> and
/// <see cref="SharedScriptLibrary"/> 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 (<c>.Result</c>/<c>.Wait()</c>) still
/// holds its worker until the callee finishes, but no longer deadlocks the scheduler against it.
/// Script bodies execute on the dedicated
/// <see cref="ScriptExecutionScheduler"/> threads, not the
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
@@ -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<ScriptCallResult>(request, _askTimeout);
var result = await _instanceActor.Ask<ScriptCallResult>(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<GetAlarmSnapshotResponse>(
request, _askTimeout, cancellationToken);
request, _askTimeout, cancellationToken).ConfigureAwait(false);
return response.Alarms.Select(ToScriptAlarm).ToList();
}
@@ -1294,7 +1294,7 @@ public class ScriptRuntimeContext
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
internal async Task<IReadOnlyList<ScriptAlarm>> GetMesReportableAlarmsAsync(CancellationToken cancellationToken = default)
=> (await GetAlarmsAsync(cancellationToken)).Where(a => a.IsMesReportable).ToList();
=> (await GetAlarmsAsync(cancellationToken).ConfigureAwait(false)).Where(a => a.IsMesReportable).ToList();
/// <summary>
/// Projects a retained <see cref="AlarmStateChanged"/> 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<NotificationStatusResponse>(query, _askTimeout);
.Ask<NotificationStatusResponse>(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",
@@ -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;
}
@@ -254,4 +254,24 @@ public class AlarmsAccessorTests : TestKit, IDisposable
Assert.Single(await pending);
}
/// <summary>
/// The helper's continuation must not capture the script scheduler: a script that blocks its
/// only worker on the task (<c>.Wait()</c>) still completes (review 2026-10-21 01/SR-U2).
/// </summary>
[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");
}
}