fix(scriptanalysis): warn on scripts blocking on a Task (.Result/.Wait()/GetAwaiter().GetResult()) (review 2026-10-21 01/SR-U2)

Advisory, not a trust-policy violation: every policy entry blocks deploy, and
an error here would fail the next redeploy of already-deployed scripts.
ValidationService surfaces it as a non-blocking ScriptCompilation warning.
This commit is contained in:
Joseph Doherty
2026-10-22 06:22:01 -04:00
parent 2ca60f86ff
commit 5919a71b8b
8 changed files with 270 additions and 17 deletions
@@ -0,0 +1,93 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
/// <summary>
/// Advisory (non-blocking) analysis that finds scripts synchronously blocking on a
/// <see cref="Task"/> — <c>.Result</c>, <c>.Wait(...)</c>, <c>Task.WaitAll/WaitAny</c>
/// and <c>.GetAwaiter().GetResult()</c> (review 2026-10-21 01/SR-U2).
///
/// <para>
/// Site scripts run on the dedicated <c>ScriptExecutionScheduler</c> pool, and not
/// every runtime helper completes off that pool, so a script that blocks on a helper's
/// task can hold a worker while the helper's continuation waits for a worker — enough
/// concurrent runs and the pool starves until the stuck-script watchdog detaches
/// workers. <c>await</c> is always the right form. This is an advisory rather than a
/// <see cref="ScriptTrustPolicy"/> violation: the trust policy is a deny-list over API
/// roots whose every entry blocks deploy, and turning this into a deploy-blocking
/// error would break already-deployed scripts on their next redeploy.
/// </para>
///
/// <para>
/// Resolution is semantic (the script is bound against <c>globalsType</c>), so a
/// member that merely happens to be named <c>Result</c> or <c>Wait</c> on a non-Task
/// type is not reported. Unresolved code is silently skipped — compile errors are the
/// compile gate's job.
/// </para>
/// </summary>
public static class BlockingWaitAnalyzer
{
private const string TasksNamespace = "System.Threading.Tasks";
private const string CompilerServicesNamespace = "System.Runtime.CompilerServices";
/// <summary>
/// Returns one advisory message per blocking wait found in <paramref name="code"/>.
/// Empty when the script never blocks on a task (or is blank).
/// </summary>
/// <param name="code">The C# script source.</param>
/// <param name="globalsType">The globals type the script binds against (e.g. <see cref="ScriptCompileSurface"/>).</param>
/// <returns>Advisory messages naming the member and its line.</returns>
public static IReadOnlyList<string> Find(string code, Type? globalsType)
{
if (string.IsNullOrWhiteSpace(code))
return Array.Empty<string>();
var compilation = RoslynScriptCompiler.CreateScript(code, globalsType).GetCompilation();
var tree = compilation.SyntaxTrees.Single();
var model = compilation.GetSemanticModel(tree);
var advisories = new List<string>();
foreach (var access in tree.GetRoot().DescendantNodes().OfType<MemberAccessExpressionSyntax>())
{
var symbol = model.GetSymbolInfo(access).Symbol;
var form = symbol switch
{
IPropertySymbol { Name: "Result" } p when IsTaskType(p.ContainingType) => ".Result",
IMethodSymbol { Name: "Wait" } m when IsTaskType(m.ContainingType) => ".Wait()",
IMethodSymbol { Name: "WaitAll" or "WaitAny" } m when IsTaskType(m.ContainingType) => $"Task.{m.Name}()",
IMethodSymbol { Name: "GetResult" } m when IsAwaiterType(m.ContainingType) => ".GetAwaiter().GetResult()",
_ => null,
};
if (form == null)
continue;
var line = access.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
advisories.Add(
$"blocks on a Task with '{form}' (line {line}) — use 'await' instead; a blocked " +
"script-pool worker can starve the pool the awaited work needs to complete.");
}
return advisories;
}
private static bool IsTaskType(INamedTypeSymbol? type)
{
for (var t = type; t != null; t = t.BaseType)
{
if (t.Name is "Task" or "ValueTask"
&& t.ContainingNamespace?.ToDisplayString() == TasksNamespace)
{
return true;
}
}
return false;
}
private static bool IsAwaiterType(INamedTypeSymbol? type)
=> type != null
&& type.Name.EndsWith("Awaiter", StringComparison.Ordinal)
&& (type.ContainingNamespace?.ToDisplayString() == CompilerServicesNamespace
|| type.ContainingType?.ContainingNamespace?.ToDisplayString() == CompilerServicesNamespace);
}
@@ -69,20 +69,7 @@ public static class RoslynScriptCompiler
IEnumerable<MetadataReference>? extraReferences = null,
IEnumerable<string>? extraImports = null)
{
var references = ScriptTrustPolicy.DefaultReferences.ToList();
if (extraReferences != null)
references.AddRange(extraReferences);
var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable();
if (extraImports != null)
imports = imports.Concat(extraImports);
var options = ScriptOptions.Default
.WithReferences(references)
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
.WithImports(imports);
var script = CSharpScript.Create<object?>(code, options, globalsType, SharedNonLoadingLoader);
var script = CreateScript(code, globalsType, extraReferences, extraImports);
// Diagnostics only — NO emit, NO load. `Script.Compile()` builds the
// executor, which emits the PE image and loads it through
@@ -104,4 +91,36 @@ public static class RoslynScriptCompiler
.Select(d => d.GetMessage())
.ToList();
}
/// <summary>
/// Builds the design-time <see cref="Script{T}"/> with the trust-model references,
/// imports, the shared metadata resolver and the shared non-loading assembly
/// loader. Callers must only read its compilation — never run it.
/// </summary>
/// <param name="code">The C# script source.</param>
/// <param name="globalsType">Optional globals type the script binds against.</param>
/// <param name="extraReferences">Optional additional metadata references.</param>
/// <param name="extraImports">Optional additional namespace imports.</param>
/// <returns>The unexecuted script.</returns>
internal static Script<object?> CreateScript(
string code,
Type? globalsType,
IEnumerable<MetadataReference>? extraReferences = null,
IEnumerable<string>? extraImports = null)
{
var references = ScriptTrustPolicy.DefaultReferences.ToList();
if (extraReferences != null)
references.AddRange(extraReferences);
var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable();
if (extraImports != null)
imports = imports.Concat(extraImports);
var options = ScriptOptions.Default
.WithReferences(references)
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
.WithImports(imports);
return CSharpScript.Create<object?>(code, options, globalsType, SharedNonLoadingLoader);
}
}
@@ -99,4 +99,40 @@ public class ScriptCompiler
? Result<bool>.Success(true)
: Result<bool>.Failure($"Script '{scriptName}' {error}");
}
/// <summary>
/// Non-blocking advisories for a script that already compiles — today, synchronous
/// blocking on a <see cref="System.Threading.Tasks.Task"/> (<c>.Result</c>,
/// <c>.Wait()</c>, <c>.GetAwaiter().GetResult()</c>), which can starve the site's
/// script-execution pool (review 2026-10-21 01/SR-U2). Memoised process-wide like
/// the compile verdict, under its own surface key. A faulting analysis yields no
/// advisories (and is not cached) — an advisory must never fail validation.
/// </summary>
/// <param name="code">The C# script code.</param>
/// <returns>Advisory messages; empty when there is nothing to flag.</returns>
public IReadOnlyList<string> FindAdvisories(string code)
{
if (string.IsNullOrWhiteSpace(code))
return [];
try
{
var (_, joined) = ScriptCompileVerdictCache.GetOrAdd(AdvisorySurface, code, () =>
{
var advisories = BlockingWaitAnalyzer.Find(code, typeof(ScriptCompileSurface));
return (true, advisories.Count == 0 ? null : string.Join(AdvisorySeparator, advisories));
});
return joined == null ? [] : joined.Split(AdvisorySeparator);
}
catch (Exception)
{
return [];
}
}
/// <summary>Cache surface key for the advisory pass (distinct from the compile verdict).</summary>
private const string AdvisorySurface = nameof(ScriptCompileSurface) + "/advisories";
/// <summary>Separator for the advisories packed into one cached string.</summary>
private const string AdvisorySeparator = "\n";
}
@@ -269,7 +269,8 @@ public class ValidationService
}
/// <summary>
/// Validates that all scripts compile successfully using the ScriptCompiler.
/// Validates that all scripts compile successfully using the ScriptCompiler, and adds
/// non-blocking warnings for compiled scripts that block synchronously on a Task.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors for each script that fails compilation.</returns>
@@ -286,6 +287,16 @@ public class ValidationService
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
script.CanonicalName));
continue;
}
// Advisories never gate deploy (review 2026-10-21 01/SR-U2): a script that
// blocks on a Task still deploys, but the operator sees why it is risky.
foreach (var advisory in _scriptCompiler.FindAdvisories(script.Code))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ScriptCompilation,
$"Script '{script.CanonicalName}' {advisory}",
script.CanonicalName));
}
}