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:
@@ -4,8 +4,8 @@
|
||||
|---|---|
|
||||
| Module | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 0 |
|
||||
|
||||
@@ -357,3 +357,73 @@ path) rather than rendering arrows, or reconstruct an actual cycle path within t
|
||||
(a single DFS back-edge walk) before formatting.
|
||||
|
||||
**Resolution:** Resolved 2026-05-23 — `DependencyCycleException.BuildMessage` now formats each cycle as `cycle members: A, B, C` (comma-separated set) rather than the misleading `A -> B -> C -> A` arrow form. Added a regression test asserting the message contains the word "member" and does not fabricate an edge sequence.
|
||||
|
||||
---
|
||||
|
||||
## Re-review 2026-06-19 (commit 7286d320)
|
||||
|
||||
All 13 prior findings remain Resolved (no regressions found). Three new findings were added.
|
||||
|
||||
### Re-review checklist
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Core.VirtualTags-014, Core.VirtualTags-015 |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | No issues found |
|
||||
| 4 | Error handling & resilience | No issues found |
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | No issues found |
|
||||
| 7 | Design-document adherence | No issues found |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | Core.VirtualTags-015 (test added as part of fix) |
|
||||
| 10 | Documentation & comments | Core.VirtualTags-016 |
|
||||
|
||||
### Core.VirtualTags-014
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:400` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `AreInputsReady` checked `kv.Value.Value is null` in addition to the `StatusCode` bad-bit test. A `DataValueSnapshot(null, 0u, ...)` — produced when a script returns `null` (legal for String and any nullable path) or when `ctx.SetVirtualTag(path, null)` is called — has Good quality (StatusCode = 0) but a null Value. The null-value check caused `AreInputsReady` to return `false` for this snapshot, so every downstream change-triggered dependent of that tag was permanently stuck at `BadWaitingForInitialData` with no diagnostic. There was no log message explaining why the tag would not advance from that state.
|
||||
|
||||
Readiness should be determined by `StatusCode` (OPC UA quality) alone. If the upstream is Good-quality with a null value, the downstream script should run; if the script then dereferences the null unconditionally it will throw, which the outer `catch` correctly maps to `BadInternalError` — the proper sentinel for "script ran but faulted," not "inputs not yet available."
|
||||
|
||||
**Recommendation:** Remove the `kv.Value.Value is null` check from `AreInputsReady`; gate only on `(kv.Value.StatusCode & 0x80000000u) != 0`.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — removed the `kv.Value.Value is null` guard from `AreInputsReady`; readiness now gates on StatusCode bit 31 only. Updated the method XML doc to explain the reasoning. Added regression test `AreInputsReady_Good_quality_null_upstream_does_not_block_downstream` which confirmed the bug (Consumer stuck at 0x80320000) before the fix and passes after.
|
||||
|
||||
### Core.VirtualTags-015
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs:55-74` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `TimerTriggerScheduler.Start` had no guard against being called more than once on the same (non-disposed) instance. A second call silently appended a duplicate set of `Timer` objects and `TickGroup` entries to `_timers` and `_groups`, doubling the evaluation frequency for every timer group without any error, log, or diagnostic. On dispose, the duplicate timers were correctly cleaned up (both instances disposed), but between Start and Dispose every group fired twice per interval — each with its own independent `InFlight` flag, so the skip-tick guard in `OnTimer` was also defeated.
|
||||
|
||||
In the current call sites, `Start` is called exactly once per Load cycle, so the bug is latent rather than triggered by production paths. However the test suite only verified the `ObjectDisposedException` path (Start after Dispose), not the Start-Start-on-live-instance path.
|
||||
|
||||
**Recommendation:** Track a `_started` bool and throw `InvalidOperationException` on a second call.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — added `_started` field; `Start` now throws `InvalidOperationException("TimerTriggerScheduler.Start has already been called. Create a new instance for each Load cycle.")` on a second call. Added regression test `Start_called_twice_throws_InvalidOperationException`.
|
||||
|
||||
### Core.VirtualTags-016
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:472-475` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The `catch` block inside `CoerceResult` contained the comment "Caller logs + maps to BadTypeMismatch — we let null propagate so the outer evaluation path sets the Bad quality." The outer evaluation path at lines 348-350 maps a coercion failure (`raw is not null && coerced is null`) to `BadInternalError` (0x80020000), not `BadTypeMismatch` (0x80740000). The code was correct; only the comment named the wrong status code, which could mislead a maintainer reading the OPC UA status code tables.
|
||||
|
||||
**Recommendation:** Correct the comment to name `BadInternalError`.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — comment updated to read "Caller maps null to BadInternalError (0x80020000) — we let null propagate so the outer evaluation path sets the Bad quality on the snapshot."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user