Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/DynamoCore/Engine/CodeGeneration/AstBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@
#endif

var scopedNode = node as ScopedNodeModel;
IEnumerable<AssociativeNode> astNodes =
IEnumerable<AssociativeNode> astNodes =
scopedNode != null
? scopedNode.BuildAstInScope(inputAstNodes, verboseLogging, this)
: node.BuildAst(inputAstNodes, context);
Expand Down Expand Up @@ -335,7 +335,24 @@

if (context == CompilationContext.DeltaExecution)
{
sortedNodes = sortedNodes.Where(n => n.IsModified);
// When any non-input node in sortedNodes is marked IsModified, the DesignScript
// compiler will assign it a new, higher PC. Other non-input nodes in sortedNodes
// (which are already limited to downstream nodes of the modified set by
// ComputeModifiedNodes) keep their old, lower PCs. Because ApplyUpdate iterates
// graph nodes in ascending PC order, those nodes execute before the modified
// upstream node, consuming stale values. The upstream then runs, changes its
// output, and marks them dirty again — causing every affected node to execute
// twice. To prevent this, when any non-input node is IsModified, include all
// non-input nodes from sortedNodes in the compilation pass. The LiveRunner
// force-recompile path injects their ASTs into the delta with new, higher PCs,
// restoring topological execution order.
// Note: IsModified covers any pending re-evaluation, not only structural changes
// (e.g. reconnects). Non-structural triggers cause harmless extra compilation;

Check warning on line 350 in src/DynamoCore/Engine/CodeGeneration/AstBuilder.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=DynamoDS_Dynamo&issues=AZ6ZT5L8xuz03-S0xzwd&open=AZ6ZT5L8xuz03-S0xzwd&pullRequest=17095
// the LiveRunner skips the force-recompile for subtrees whose AST is unchanged.
bool hasModifiedNonInputNode = sortedNodes.Any(n => n.IsModified && !n.IsInputNode);
sortedNodes = hasModifiedNonInputNode
? sortedNodes.Where(n => n.IsModified || !n.IsInputNode)
: sortedNodes.Where(n => n.IsModified);
}

var result = new List<List<AssociativeNode>>();
Expand Down
18 changes: 17 additions & 1 deletion src/Engine/ProtoCore/RuntimeData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,23 @@ public CallSite GetCallSite(int classScope, string methodName, Executable execut
csInstance.UpdateCallSite(classScope, methodName);
if (runtimeCore.Options.IsDeltaExecution)
{
runtimeCore.RuntimeStatus.ClearWarningForExpression(graphNode.exprUID);
// Clear warnings for the specific Dynamo node being executed (by GUID + exprUID).
// Using exprUID alone is unsafe because unrelated nodes can share the same exprUID
// in delta compilation (e.g. force-recompiled downstream nodes), causing one
// node's ClearWarningForExpression call to accidentally wipe another node's
// runtime warnings. Using GUID alone is unsafe when a single Dynamo node compiles
// to multiple AST expressions with distinct exprUIDs — clearing all warnings for
// the GUID would remove a sibling expression's warning before it re-executes.
// Fall back to exprUID for internal graph nodes (guid=Empty).
if (!graphNode.guid.Equals(System.Guid.Empty))
Comment thread
jasonstratton marked this conversation as resolved.
{
runtimeCore.RuntimeStatus.ClearWarningsForGraphNode(graphNode.guid, graphNode.exprUID);
}
else
{
runtimeCore.RuntimeStatus.ClearWarningForExpression(graphNode.exprUID);
}

}
}

Expand Down
18 changes: 18 additions & 0 deletions src/Engine/ProtoCore/RuntimeStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ public void ClearWarningsForGraph(Guid guid)
}
}

/// <summary>
/// Clears runtime warnings and infos that belong to a specific graph node GUID
/// AND a specific expression ID. This is more precise than <see cref="ClearWarningsForGraph"/>
/// and is needed when a single Dynamo node compiles to multiple AST expressions that each
/// get a distinct exprUID — clearing by GUID alone would remove warnings from sibling
/// expressions that have not yet been re-executed.
/// </summary>
/// <param name="guid">The graph-node GUID of the Dynamo node being re-executed.</param>
/// <param name="exprUID">The expression UID of the specific AST expression being re-executed.</param>
public void ClearWarningsForGraphNode(Guid guid, int exprUID)
Comment thread
jasonstratton marked this conversation as resolved.
{
using (rwl.CreateWriteLock())
{
warnings.RemoveAll(w => w.GraphNodeGuid.Equals(guid) && w.ExpressionID == exprUID);
infos.RemoveAll(w => w.GraphNodeGuid.Equals(guid) && w.ExpressionID == exprUID);
}
}

public void ClearWarningsForAst(int astID)
{
using (rwl.CreateWriteLock())
Expand Down
141 changes: 137 additions & 4 deletions src/Engine/ProtoScript/Runners/LiveRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,85 @@
}
}

/// <summary>
/// Compares two AST nodes by textual content rather than object identity.
/// BuildAst always creates fresh objects, so reference equality fails even for
/// semantically unchanged nodes. ToString() produces a stable, identity-independent
/// representation that reflects the actual code the node will generate.
///
/// ToString() alone is not always sufficient: FunctionCallNode.ToString() returns the
/// literal "null" for internal methods (those starting with '%') whose unprefixed name
/// doesn't parse as a binary or unary operator (e.g. "%conditionalIf" used by the If
/// node), hiding the actual arguments and their replication guides. When the stringified
/// form contains "null" — the narrow case where the textual form may be lossy — fall
/// back to the structural Equals operator (overridden on each AssociativeNode subclass
/// to do recursive content comparison) to confirm the match. ASTs that don't stringify
/// to a form containing "null" use only the fast textual comparison.
/// </summary>
private static bool AreAstNodesContentEqual(AssociativeNode a, AssociativeNode b)
{
if (ReferenceEquals(a, b)) return true;
if (a == null || b == null) return false;
var sa = a.ToString();
var sb = b.ToString();
if (sa != sb) return false;
if (sa.Contains(ProtoCore.DSDefinitions.Keyword.Null))
Comment thread
jasonstratton marked this conversation as resolved.
{
return a.Equals(b);
}
return true;
}

/// <summary>
/// Returns true if the subtree's AST nodes differ from the cached version.
/// Used to distinguish structural changes (reconnect) from identity changes (slider value).
/// Detects both additions (new nodes not in cache) and removals (cached nodes no longer present).
/// Uses content-based comparison so that freshly compiled but semantically unchanged nodes
/// are correctly detected as unmodified.
/// </summary>
private bool SubtreeHasActualChanges(Subtree subtree)
{
Subtree cached;
if (!currentSubTreeList.TryGetValue(subtree.GUID, out cached) || cached.AstNodes == null)
return subtree.AstNodes != null && subtree.AstNodes.Any();

var newNodes = subtree.AstNodes;
var cachedNodes = cached.AstNodes;

// Null or empty new AST when cache had content means all nodes were removed.
if (newNodes == null || !newNodes.Any())
return cachedNodes.Any();

// Different count means nodes were added or removed.
if (newNodes.Count() != cachedNodes.Count())

Check warning on line 743 in src/Engine/ProtoScript/Runners/LiveRunner.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Count' property here instead.

See more on https://sonarcloud.io/project/issues?id=DynamoDS_Dynamo&issues=AZ6ZT5IXxuz03-S0xzwb&open=AZ6ZT5IXxuz03-S0xzwb&pullRequest=17095

Check warning on line 743 in src/Engine/ProtoScript/Runners/LiveRunner.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Count' property here instead.

See more on https://sonarcloud.io/project/issues?id=DynamoDS_Dynamo&issues=AZ6ZT5IXxuz03-S0xzwa&open=AZ6ZT5IXxuz03-S0xzwa&pullRequest=17095
return true;

// Same count: any new node absent from the cache indicates a structural change.
// Build a lookup of cached nodes by their textual form (each stringified once) so the
// scan below is O(n+m) rather than a nested O(n*m) re-stringification. Buckets keep the
// actual nodes so the lossy "null" case can confirm via the structural Equals operator,
// preserving AreAstNodesContentEqual's semantics.
var cachedByString = new Dictionary<string, List<AssociativeNode>>();
foreach (var prev in cachedNodes)
{
var key = prev.ToString();
if (!cachedByString.TryGetValue(key, out var bucket))
cachedByString[key] = bucket = new List<AssociativeNode>();
bucket.Add(prev);
}

// A new node is "present" if some cached node matches it by content.
return newNodes.Any(node =>
{
var key = node.ToString();
if (!cachedByString.TryGetValue(key, out var bucket))
return true; // no textual match -> structural change
if (!key.Contains(ProtoCore.DSDefinitions.Keyword.Null))
return false; // textual match is sufficient -> present
return !bucket.Any(prev => node.Equals(prev)); // lossy form -> confirm structurally

Check warning on line 768 in src/Engine/ProtoScript/Runners/LiveRunner.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Collection-specific "Contains" method should be used instead of the "Any" extension.

See more on https://sonarcloud.io/project/issues?id=DynamoDS_Dynamo&issues=AZ6ZT5IXxuz03-S0xzwc&open=AZ6ZT5IXxuz03-S0xzwc&pullRequest=17095
});
}

/// <summary>
/// Update the cached ASTs in the subtree given the modified ASTs
/// </summary>
Expand Down Expand Up @@ -816,6 +895,23 @@
}
}

// When any non-input subtree has structural changes (e.g. a reconnect), delta
// compilation recompiles that node at a new, higher PC. Unchanged downstream nodes
// keep their old, lower PCs. ApplyUpdate then finds them first (lower PC = earlier
// in the graph list) and runs them with stale upstream values, then the upstream
// node executes and marks them dirty again — causing each downstream node to execute
// twice. To prevent this, force-recompile all unchanged non-input subtrees so they
// receive new, higher PCs that respect the topological execution order.
// Precompute structural-change status once per subtree; reused in the loop below
// to avoid recomputing the (ToString-based) comparison for each unchanged subtree.
var nonInputStructurallyChanged = new HashSet<Guid>();
foreach (var st in modifiedSubTrees)
{
if (!st.IsInput && SubtreeHasActualChanges(st))
nonInputStructurallyChanged.Add(st.GUID);
}
Comment on lines +908 to +912
bool anyNonInputActuallyModified = nonInputStructurallyChanged.Count > 0;

for (int n = 0; n < modifiedSubTrees.Count(); ++n)
{
var modifiedSubTree = modifiedSubTrees[n];
Expand All @@ -832,6 +928,43 @@
var modifiedASTList = GetModifiedNodes(modifiedSubTree, redefinitionAllowed, out modifiedInputAST);
csData.ModifiedNodesForRuntimeSetValue.AddRange(modifiedInputAST);

// If another non-input subtree was structurally modified and this non-input
// subtree appears unchanged, force-recompile it so its new graph node lands
// at a PC higher than the recompiled upstream node. Also deactivate the old
// graph nodes so they do not co-execute with the new ones in ApplyUpdate.
if (!modifiedSubTree.IsInput && anyNonInputActuallyModified
&& !modifiedASTList.Any() && !modifiedInputAST.Any()
&& !nonInputStructurallyChanged.Contains(modifiedSubTree.GUID))
{
Comment thread
johnpierson marked this conversation as resolved.
Subtree cachedSubTree;
if (currentSubTreeList.TryGetValue(modifiedSubTree.GUID, out cachedSubTree)
&& cachedSubTree.AstNodes != null)
{
var cachedBinaryNodes = cachedSubTree.AstNodes.OfType<BinaryExpressionNode>().ToList();
// Deactivate the old graph nodes so they are replaced by freshly compiled ones.
// These originals stay untouched: DeactivateGraphnodes/ReActivateGraphNodesInCycle
// only read OriginalAstID, which clones preserve, so deactivation still matches.
csData.RemovedBinaryNodesFromModification.AddRange(cachedBinaryNodes);
// Clone before stamping/injecting. cachedBinaryNodes are the live objects held in
// currentSubTreeList; mutating them or handing them to the compiler (which writes
// back UIDs and SSA-transforms in place) would corrupt the cached subtree and leak
// into the next delta compile. The copy constructor preserves guid, OriginalAstID
// and ExpressionUID, so the clone compiles identically to the original object.
foreach (var bnode in cachedBinaryNodes)
{
var clone = (BinaryExpressionNode)NodeUtils.Clone(bnode);
// Stamp the GUID and inject directly into the delta list so the compiler
// assigns new, higher PCs — keeping modifiedASTList empty so
// UpdateCachedASTList does not see these as modifications and double
// the cached AST list.
clone.guid = modifiedSubTree.GUID;
clone.IsInputExpression = false;
SetNestedLanguageBlockASTGuids(modifiedSubTree.GUID, new List<ProtoCore.AST.Node>() { clone });
deltaAstList.Add(clone);
}
}
}

modifiedSubTree.ModifiedAstNodes.Clear();
modifiedSubTree.ModifiedAstNodes.AddRange(modifiedASTList);
modifiedSubTree.ModifiedAstNodes.AddRange(modifiedInputAST);
Expand Down Expand Up @@ -860,7 +993,7 @@
}
else
{
// No delta computation.
// No delta computation.
// Right now it is only disabled for code block node, but we may
// completely disable it. Details about why doing so:
// https://github.com/DynamoDS/Dynamo/pull/7282
Expand Down Expand Up @@ -997,7 +1130,7 @@
bool nodeFound = false;
foreach (AssociativeNode prevNode in st.AstNodes)
{
if (prevNode.Equals(node))
if (AreAstNodesContentEqual(prevNode, node))
{
nodeFound = true;
break;
Expand Down Expand Up @@ -1098,7 +1231,7 @@
bool prevNodeFoundInNewList = false;
foreach (AssociativeNode newNode in newASTList)
{
if (prevNode.Equals(newNode))
if (AreAstNodesContentEqual(prevNode, newNode))
{
// prev node still exists in the new list
prevNodeFoundInNewList = true;
Expand Down Expand Up @@ -1127,7 +1260,7 @@
{
foreach (AssociativeNode newNode in newASTList)
{
if (prevNode.Equals(newNode))
if (AreAstNodesContentEqual(prevNode, newNode))
{
existingList.Add(prevNode);
break;
Expand Down
Loading
Loading