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
@@ -167,4 +167,37 @@ public sealed class TimerTriggerSchedulerTests
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromMilliseconds(50))]));
}
// ----- Core.VirtualTags-015: Start must not be callable twice on the same instance -----
/// <summary>
/// Verifies that a second call to Start on the same (non-disposed) scheduler
/// throws InvalidOperationException. Without this guard, calling Start twice
/// appends a duplicate set of timers and TickGroups, causing every timer-group
/// to fire and evaluate twice per interval with no diagnostic.
/// </summary>
[Fact]
public void Start_called_twice_throws_InvalidOperationException()
{
// Regression for Core.VirtualTags-015: without the _started guard, two Start
// calls silently double the timer count and the evaluation frequency.
var up = new FakeUpstream();
var logger = new LoggerConfiguration().CreateLogger();
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger), logger);
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromSeconds(10))]);
using var sched = new TimerTriggerScheduler(engine, logger);
sched.Start([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromSeconds(10))]);
// Second call on the same live instance must throw — not silently create duplicates.
Should.Throw<InvalidOperationException>(() =>
sched.Start([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromSeconds(10))]));
}
}
@@ -664,6 +664,54 @@ public sealed class VirtualTagEngineTests
fired.ShouldBeFalse("no evaluation has happened, observer must not fire");
}
// ----- Core.VirtualTags-014: AreInputsReady must not block on Good-quality null value -----
/// <summary>
/// Verifies that a Good-quality upstream snapshot with a null Value (e.g. a
/// script returning null) does not permanently block downstream change-triggered
/// dependents at BadWaitingForInitialData. A Good-quality null means "the
/// upstream is online and returned null" — readiness is determined by StatusCode,
/// not by the value's nullability.
/// </summary>
[Fact]
public async Task AreInputsReady_Good_quality_null_upstream_does_not_block_downstream()
{
// Regression for Core.VirtualTags-014: before the fix, AreInputsReady checked
// kv.Value.Value is null, so a Good-quality DataValueSnapshot(null, 0u, ...) —
// produced by an upstream script returning null — kept every downstream stuck at
// BadWaitingForInitialData forever. The fix gates only on StatusCode (bit 31).
var up = new FakeUpstream();
up.Set("In", 1);
using var engine = Build(up);
engine.Load([
// Producer script returns null — stores DataValueSnapshot(null, Good, ...).
new VirtualTagDefinition("NullProducer", DriverDataType.String,
"""return null;"""),
// Consumer depends on NullProducer and is change-triggered.
// With the bug: Consumer is stuck at BadWaitingForInitialData.
// After the fix: Consumer evaluates and surfaces the NRE as BadInternalError
// (since it casts null unconditionally), which is the correct sentinel for
// "the script ran but produced an error" rather than "inputs not ready".
new VirtualTagDefinition("Consumer", DriverDataType.String,
"""return (string)ctx.GetTag("NullProducer").Value + "_suffix";""",
ChangeTriggered: true),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
// NullProducer should be Good with null value (script returned null).
var producerResult = engine.Read("NullProducer");
producerResult.StatusCode.ShouldBe(0u, "Good quality: script returned null cleanly");
producerResult.Value.ShouldBeNull();
// Consumer must NOT be stuck at BadWaitingForInitialData — it should have
// evaluated (and produced BadInternalError from the cast of null + "_suffix").
var consumerResult = engine.Read("Consumer");
consumerResult.StatusCode.ShouldNotBe(0x80320000u,
"Consumer must not be stuck at BadWaitingForInitialData when its upstream is Good-quality");
}
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);