review(Core.VirtualTags): fix Good-null upstream blocking downstream (Medium)

Re-review at 7286d320. -014 (Medium): AreInputsReady gated on value!=null, so a script
returning null (Good quality) permanently blocked change-triggered dependents at
BadWaitingForInitialData; now gates on the StatusCode Good bit only + test. -015:
TimerTriggerScheduler.Start throws on double-call. -016: fix wrong status-code comment.
This commit is contained in:
Joseph Doherty
2026-06-19 11:21:35 -04:00
parent 272a9da61e
commit 48af117bff
5 changed files with 172 additions and 10 deletions
@@ -28,6 +28,7 @@ public sealed class TimerTriggerScheduler : IDisposable
private readonly CancellationTokenSource _cts = new();
private long _skippedTickCount;
private bool _disposed;
private bool _started;
/// <summary>Initializes a new instance of the <see cref="TimerTriggerScheduler"/> class.</summary>
/// <param name="engine">The virtual tag engine to trigger evaluations on.</param>
@@ -52,9 +53,13 @@ public sealed class TimerTriggerScheduler : IDisposable
/// behavior.
/// </summary>
/// <param name="definitions">The virtual tag definitions to schedule timers for.</param>
/// <exception cref="InvalidOperationException">Thrown when <c>Start</c> is called more than once on the same instance.</exception>
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
if (_started) throw new InvalidOperationException(
"TimerTriggerScheduler.Start has already been called. Create a new instance for each Load cycle.");
_started = true;
var byInterval = definitions
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
@@ -387,17 +387,23 @@ public sealed class VirtualTagEngine : IDisposable
}
/// <summary>
/// Returns true when every entry in <paramref name="cache"/> has a non-null value
/// and a Good/Uncertain-quality <see cref="DataValueSnapshot.StatusCode"/>. Scripts
/// cast <c>ctx.GetTag(path).Value</c> unconditionally; short-circuiting before the
/// script runs keeps a not-yet-cached upstream from faulting the virtual tag with
/// <c>BadInternalError</c>.
/// Returns true when every entry in <paramref name="cache"/> has a
/// Good/Uncertain-quality <see cref="DataValueSnapshot.StatusCode"/> (bit 31 = 0).
/// Readiness is gated on <c>StatusCode</c> alone — a Good-quality snapshot with a
/// null <c>Value</c> (e.g. a script that returns <c>null</c>, or a
/// <c>ctx.SetVirtualTag</c> call with a null argument) is considered ready and the
/// downstream script is allowed to run. The downstream script will observe the null
/// value and typically throw an NRE, which the outer evaluation catch maps to
/// <c>BadInternalError</c> — the correct sentinel for "script ran but faulted"
/// rather than "inputs not yet available". Previously the null-value check
/// caused a Good-quality null upstream to permanently block all its dependents at
/// <c>BadWaitingForInitialData</c> (Core.VirtualTags-014).
/// </summary>
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
foreach (var kv in cache)
{
if (kv.Value is null || kv.Value.Value is null) return false;
if (kv.Value is null) return false;
if ((kv.Value.StatusCode & 0x80000000u) != 0) return false;
}
return true;
@@ -470,8 +476,8 @@ public sealed class VirtualTagEngine : IDisposable
}
catch
{
// Caller logs + maps to BadTypeMismatch — we let null propagate so the
// outer evaluation path sets the Bad quality.
// Caller maps null to BadInternalError (0x80020000) — we let null propagate
// so the outer evaluation path sets the Bad quality on the snapshot.
return null;
}
}