fix(core-virtual-tags): resolve Low code-review findings (Core.VirtualTags-004,006,007,009,010,011,013)
- Core.VirtualTags-004: CoerceResult now covers every scalar DriverDataType and throws on the default arm; Load rejects unsupported declared types. - Core.VirtualTags-006: Subscribe/Unsub prune empty observer-list entries from _observers under the same lock with a reconfirm-on-add race guard. - Core.VirtualTags-007: rewrote TimerTriggerScheduler so each TickGroup tracks an InFlight flag (Interlocked CAS); ticks that overlap a still- running tick for the same group are skipped + counted. - Core.VirtualTags-009: DirectDependencies / DirectDependents return a shared static empty set on miss instead of allocating per call. - Core.VirtualTags-010: corrected XML docs to reference the real engine symbols (OnUpstreamChange, CascadeAsync, etc.) instead of phantom types. - Core.VirtualTags-011: Load now rejects scripts whose declared Writes target a non-registered virtual-tag path. - Core.VirtualTags-013: DependencyCycleException renders SCC members as a set rather than a fabricated arrow-traversal edge path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,65 @@ public sealed class DependencyGraphTests
|
||||
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-013: DependencyCycleException message must not present SCC as edge path -----
|
||||
|
||||
[Fact]
|
||||
public void DependencyCycleException_message_describes_cycle_members_not_a_fabricated_edge_path()
|
||||
{
|
||||
// Regression for Core.VirtualTags-013: Tarjan returns SCC members in stack-pop
|
||||
// order, NOT in edge-traversal order. The exception message must not render the
|
||||
// members as "A -> B -> C -> A" — that misleads operators into looking for an
|
||||
// edge that may not be in the config. Instead the message uses a set-form
|
||||
// ("members: A, B, C") or a labelled traversal.
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
var ex = Should.Throw<DependencyCycleException>(() => g.TopologicalSort());
|
||||
|
||||
// The arrow ("->") notation as used previously (string.Join(" -> ", c) + " -> " + c[0])
|
||||
// implies an ordered edge path. After the fix, the message must NOT contain the
|
||||
// closing edge `-> A` (i.e. " -> " + first-member) on its own — the formatting
|
||||
// must clearly mark the list as cycle MEMBERS rather than an edge sequence.
|
||||
ex.Message.ShouldContain("cycle");
|
||||
ex.Message.ShouldContain("A");
|
||||
ex.Message.ShouldContain("B");
|
||||
// Verify the message uses a member-list framing ("members:" or "members of cycle"
|
||||
// or commas) rather than the misleading edge-path framing.
|
||||
ex.Message.ShouldContain("member", Case.Insensitive,
|
||||
"message should label entries as cycle members, not present them as an edge path");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-009: empty-set allocation on miss -----
|
||||
|
||||
[Fact]
|
||||
public void DirectDependencies_miss_returns_shared_empty_set_instance()
|
||||
{
|
||||
// Regression for Core.VirtualTags-009: calling DirectDependencies for an
|
||||
// unregistered node should NOT allocate a fresh HashSet each time. The miss
|
||||
// path returns a shared empty set so the change-cascade hot path doesn't
|
||||
// churn the GC.
|
||||
var g = new DependencyGraph();
|
||||
var a = g.DirectDependencies("Unknown1");
|
||||
var b = g.DirectDependencies("Unknown2");
|
||||
a.ShouldBeEmpty();
|
||||
b.ShouldBeEmpty();
|
||||
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectDependents_miss_returns_shared_empty_set_instance()
|
||||
{
|
||||
// Same regression as above for DirectDependents — called from inside the
|
||||
// CascadeAsync DFS and TopologicalSort Kahn loop, so the miss-path allocation
|
||||
// is on every change-cascade event.
|
||||
var g = new DependencyGraph();
|
||||
var a = g.DirectDependents("LeafA");
|
||||
var b = g.DirectDependents("LeafB");
|
||||
a.ShouldBeEmpty();
|
||||
b.ShouldBeEmpty();
|
||||
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deep_graph_no_stack_overflow()
|
||||
{
|
||||
|
||||
@@ -92,6 +92,53 @@ public sealed class TimerTriggerSchedulerTests
|
||||
}));
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-007: timer ticks must not block pool threads and must skip when prior tick is still running -----
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_skips_when_prior_tick_for_the_same_group_is_still_running()
|
||||
{
|
||||
// Regression for Core.VirtualTags-007: if a single tick takes longer than the
|
||||
// interval, subsequent timer callbacks must NOT each pin a thread-pool thread
|
||||
// waiting on the same evaluation gate. The scheduler tracks an in-flight flag
|
||||
// per group and skips a new tick when the prior one is still running.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
// Slow script — each evaluation takes longer than several timer intervals.
|
||||
const int slowMs = 250;
|
||||
const int intervalMs = 50;
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Slow", DriverDataType.Int32,
|
||||
$$"""
|
||||
var end = DateTime.UtcNow.AddMilliseconds({{slowMs}});
|
||||
while (DateTime.UtcNow < end) { }
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"Slow", DriverDataType.Int32,
|
||||
"",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
|
||||
|
||||
// Wait long enough for many timer ticks at 50ms while one evaluation
|
||||
// (~250ms each) holds the engine. Window is 600ms ~ 12 ticks.
|
||||
await Task.Delay(600);
|
||||
|
||||
// With the fix in place, ticks that fire while the previous one for the same
|
||||
// group is still running are skipped. The skipped count must be measurable; if
|
||||
// SkippedTickCount is still 0 after 600ms with ~12 ticks fired and a 250ms eval,
|
||||
// the fix is not working — at minimum 3-4 ticks must have been skipped.
|
||||
sched.SkippedTickCount.ShouldBeGreaterThan(2,
|
||||
"ticks that fire while the prior tick for the same group is still running must be skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposed_scheduler_stops_firing()
|
||||
{
|
||||
|
||||
@@ -400,24 +400,25 @@ public sealed class VirtualTagEngineTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_on_non_registered_path_logs_warning_and_does_not_throw()
|
||||
public async Task SetVirtualTag_on_non_registered_path_is_caught_at_Load()
|
||||
{
|
||||
// Arrange: script writes to a path that is not a registered virtual tag.
|
||||
// Originally validated the runtime warning-and-drop branch in OnScriptSetVirtualTag.
|
||||
// After Core.VirtualTags-011 the static DependencyExtractor.Writes set is validated
|
||||
// at Load time, so a literal-string write to a non-existent path is now rejected
|
||||
// at publish — the dynamic warning path is reserved as a defensive guard for cases
|
||||
// the static extractor cannot see (currently none, since dynamic paths are also
|
||||
// rejected at extraction).
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonExistentPath", 99);
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""")]);
|
||||
|
||||
// Act + Assert: should not throw; engine stays healthy.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Writer").StatusCode.ShouldBe(0u, "engine must not fault on write to non-registered path");
|
||||
engine.Read("Writer").Value.ShouldBe(1);
|
||||
Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonExistentPath", 99);
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""")
|
||||
])).Message.ShouldContain("NonExistentPath");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -451,6 +452,136 @@ public sealed class VirtualTagEngineTests
|
||||
engine.Read("Bad").Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-011: Writes target validation at Load time -----
|
||||
|
||||
[Fact]
|
||||
public async Task Load_rejects_script_writing_to_unregistered_virtual_tag_path()
|
||||
{
|
||||
// Regression for Core.VirtualTags-011: a script that calls
|
||||
// ctx.SetVirtualTag("Typo", ...) must be caught at publish/load time rather than
|
||||
// silently dropped at runtime, so operator typos surface as a publish failure.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonRegisteredTarget", 1);
|
||||
return 0;
|
||||
"""),
|
||||
new VirtualTagDefinition("RegisteredTarget", DriverDataType.Int32,
|
||||
"""return 1;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("Writer");
|
||||
ex.Message.ShouldContain("NonRegisteredTarget");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_accepts_script_writing_to_registered_virtual_tag_path()
|
||||
{
|
||||
// Companion to the rejection test: a write to a registered tag must continue to
|
||||
// load successfully.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
// No throw — Writer writes to Target which is registered.
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false),
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("Target", (int)ctx.GetTag("In").Value);
|
||||
return 0;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Target").Value.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-006: empty observer list left in _observers map -----
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_then_Unsub_prunes_empty_observer_list_for_path()
|
||||
{
|
||||
// Regression for Core.VirtualTags-006: disposing the last subscriber for a path
|
||||
// must remove the dictionary entry so a long-running server with churning OPC UA
|
||||
// subscriptions does not accumulate an unbounded number of empty List entries.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
// Subscribe, then immediately Dispose — both the only observer.
|
||||
var sub1 = engine.Subscribe("T", (_, _) => { });
|
||||
var sub2 = engine.Subscribe("T", (_, _) => { });
|
||||
sub1.Dispose();
|
||||
sub2.Dispose();
|
||||
|
||||
// The internal map should no longer hold an entry for the path.
|
||||
// Use the same ConcurrentDictionary type the engine uses; we check via reflection
|
||||
// on the test-private field so this is robust to future renames inside engine.
|
||||
var observersField = typeof(VirtualTagEngine).GetField(
|
||||
"_observers",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
observersField.ShouldNotBeNull();
|
||||
var observers = observersField!.GetValue(engine);
|
||||
observers.ShouldNotBeNull();
|
||||
var containsKey = observers!.GetType().GetMethod("ContainsKey")!;
|
||||
var result = (bool)containsKey.Invoke(observers, new object[] { "T" })!;
|
||||
result.ShouldBeFalse("disposing the last subscriber must remove the dictionary entry");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-004: CoerceResult default arm leaks uncoerced values -----
|
||||
|
||||
[Fact]
|
||||
public async Task CoerceResult_handles_Int16_UInt16_UInt32_UInt64()
|
||||
{
|
||||
// Regression for Core.VirtualTags-004: before the fix, CoerceResult had a default
|
||||
// arm that returned the script's raw double/string for these types, producing a
|
||||
// type-mismatched DataValueSnapshot. Verify every integer DriverDataType the engine
|
||||
// is allowed to declare coerces correctly.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("AsInt16", DriverDataType.Int16, """return 7.0;"""),
|
||||
new VirtualTagDefinition("AsUInt16", DriverDataType.UInt16, """return 8.0;"""),
|
||||
new VirtualTagDefinition("AsUInt32", DriverDataType.UInt32, """return 9.0;"""),
|
||||
new VirtualTagDefinition("AsUInt64", DriverDataType.UInt64, """return 10.0;"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("AsInt16").Value.ShouldBeOfType<short>();
|
||||
engine.Read("AsInt16").Value.ShouldBe((short)7);
|
||||
engine.Read("AsUInt16").Value.ShouldBeOfType<ushort>();
|
||||
engine.Read("AsUInt16").Value.ShouldBe((ushort)8);
|
||||
engine.Read("AsUInt32").Value.ShouldBeOfType<uint>();
|
||||
engine.Read("AsUInt32").Value.ShouldBe((uint)9);
|
||||
engine.Read("AsUInt64").Value.ShouldBeOfType<ulong>();
|
||||
engine.Read("AsUInt64").Value.ShouldBe((ulong)10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_rejects_definition_with_unsupported_DriverDataType()
|
||||
{
|
||||
// Regression for Core.VirtualTags-004: any DriverDataType that CoerceResult cannot
|
||||
// honour must be rejected at Load time so an operator typo (or a future enum
|
||||
// member added without coercion support) does not silently emit a type-mismatched
|
||||
// value to OPC UA clients. Reference is unsupported for virtual tags (the engine
|
||||
// does not synthesize Galaxy attribute references).
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Ref", DriverDataType.Reference, """return "Some.Attribute";"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("Reference");
|
||||
ex.Message.ShouldContain("Ref");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_rejects_duplicate_path_with_aggregated_error()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user