fix(virtual-tags): resolve High code-review finding (Core.VirtualTags-001)

OnScriptSetVirtualTag updated the value cache, notified observers, and
recorded history for the written path but never scheduled a cascade for
tags depending on that path. This contradicts docs/VirtualTags.md, which
states ctx.SetVirtualTag writes "still participate in change-trigger
cascades": a change-triggered virtual tag reading a script-written tag
went stale until an unrelated trigger fired.

OnScriptSetVirtualTag now launches a fire-and-forget CascadeAsync for the
written path, mirroring OnUpstreamChange. The cascade is scheduled rather
than invoked inline because the callback runs inside EvaluateInternalAsync
while the non-reentrant _evalGate semaphore is held.

Added regression test
SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:17:40 -04:00
parent e3f8fa535a
commit 66e8bfbab3
3 changed files with 43 additions and 3 deletions

View File

@@ -315,6 +315,14 @@ public sealed class VirtualTagEngine : IDisposable
_valueCache[path] = snap;
NotifyObservers(path, snap);
if (_tags[path].Definition.Historize) _history.Record(path, snap);
// A cross-tag write must participate in the change-trigger cascade, exactly
// like an upstream delta — any change-triggered tag that reads this path
// would otherwise go stale until an unrelated trigger fires (see
// docs/VirtualTags.md, VirtualTagContext section). Fire-and-forget: this
// callback runs inside EvaluateInternalAsync with the non-reentrant
// _evalGate held, so the cascade must be scheduled, not invoked inline.
_ = CascadeAsync(path, CancellationToken.None);
}
private void NotifyObservers(string path, DataValueSnapshot value)