Skip to content

Commit 5924fe6

Browse files
CopilotmarcschierCopilotmrsuciu
authored
Synchronize ConditionState branch collection access (#3895)
# Description `ConditionState.m_branches` (a plain `Dictionary`) was read and written from multiple threads without synchronization. A state change on a background thread could mutate the dictionary while readers (`GetRetainState`/`UpdateRetainState`, `ConditionRefresh`, `GetBranch`) enumerated it, yielding torn entries and native access violations (observed as `IsBranch` on a null `this`). This ports the master378 fix to `master`, where the type lives at `Stack/Opc.Ua.Core.Types/State/ConditionState.cs` and uses `ByteString`/nullable refs. - **Lock all branch access**: added a private `m_branchesLock` guarding every read/write of `m_branches` (`CreateBranch`, `GetBranch`, `ReplaceBranchEvent`, `RemoveBranchEvent`, `ClearBranches`, `GetBranchCount`). - **Snapshot enumeration**: `GetBranches()` now returns a copy taken under the lock, so internal readers and `AlarmHolder.GetBranchesForConditionRefresh` iterate a stable collection while other threads mutate the live one. Public signature is unchanged. - **Test**: added `ConcurrentBranchAccessIsThreadSafe`, stressing concurrent create/clear writers against enumerate/count readers. `lock` is used rather than `SemaphoreSlim` because all affected methods are synchronous. Each `ConditionState` holds its own lock, so the recursive `UpdateRetainState` traversal across branches neither contends nor deadlocks. `ConcurrentDictionary` was rejected: it would change the public return type and still leave compound read-modify-write sequences (e.g. `ReplaceBranchEvent`) unprotected. ## Related Issues ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Suciu Mircea Adrian <Mircea-Adrian.Suciu@softing.com>
1 parent 35708e8 commit 5924fe6

6 files changed

Lines changed: 134 additions & 40 deletions

File tree

Libraries/Opc.Ua.Client/CoreClientUtils.NodeSetExport.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,14 @@ public static void ExportNodesToNodeSet2(
109109
/// <param name="nodes">The list of nodes to export.</param>
110110
/// <param name="outputStream">The output stream to write the NodeSet2 XML to.</param>
111111
/// <param name="options">Options controlling the export behavior.</param>
112+
/// <param name="lastModified">The last-modified timestamp to embed, or <c>null</c> to use the current time.</param>
112113
/// <exception cref="ArgumentNullException"><paramref name="nodes"/> is <c>null</c>.</exception>
113114
public static void ExportNodesToNodeSet2(
114115
ISystemContext context,
115116
IList<INode> nodes,
116117
Stream outputStream,
117-
NodeSetExportOptions options)
118+
NodeSetExportOptions options,
119+
DateTime? lastModified = null)
118120
{
119121
if (context == null)
120122
{
@@ -148,7 +150,7 @@ public static void ExportNodesToNodeSet2(
148150
}
149151

150152
// Use the existing export functionality
151-
nodeStates.SaveAsNodeSet2(context, outputStream);
153+
nodeStates.SaveAsNodeSet2(context, outputStream, null, lastModified);
152154
}
153155

154156
/// <summary>

Stack/Opc.Ua.Core/Stack/State/ConditionState.cs

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
using System;
3131
using System.Collections.Generic;
3232
using System.Linq;
33+
using System.Threading;
3334

3435
namespace Opc.Ua
3536
{
@@ -197,9 +198,10 @@ public virtual ConditionState CreateBranch(ISystemContext context, NodeId branch
197198

198199
string postEventId = Utils.ToHexString(branchedNodeState.EventId.Value);
199200

200-
Dictionary<string, ConditionState> branches = GetBranches();
201-
202-
branches.Add(postEventId, branchedNodeState);
201+
lock (m_branchesLock)
202+
{
203+
(m_branches ??= []).Add(postEventId, branchedNodeState);
204+
}
203205

204206
state = branchedNodeState;
205207
}
@@ -208,15 +210,22 @@ public virtual ConditionState CreateBranch(ISystemContext context, NodeId branch
208210
}
209211

210212
/// <summary>
211-
/// Retrieves the branches for the current ConditionState.
212-
/// Creates the branches dictionary if required
213+
/// Returns a snapshot of the current branches for this ConditionState.
213214
/// </summary>
214215
/// <remarks>
215216
/// Function exists because constructor is in auto generated code.
217+
/// Returns a snapshot copy so the caller can safely enumerate it
218+
/// while other threads concurrently modify the branch collection.
219+
/// If no branches exist, an empty dictionary is returned.
216220
/// </remarks>
217221
public Dictionary<string, ConditionState> GetBranches()
218222
{
219-
return m_branches ??= [];
223+
lock (m_branchesLock)
224+
{
225+
return m_branches != null
226+
? new Dictionary<string, ConditionState>(m_branches)
227+
: [];
228+
}
220229
}
221230

222231
/// <summary>
@@ -241,20 +250,21 @@ public virtual ConditionState GetEventByEventId(byte[] eventId)
241250
/// <returns>ConditionState branch if it exists</returns>
242251
public ConditionState GetBranch(byte[] eventId)
243252
{
244-
ConditionState alarm = null;
245-
246-
Dictionary<string, ConditionState> branches = GetBranches();
247-
248-
foreach (ConditionState branchEvent in branches.Values)
253+
lock (m_branchesLock)
249254
{
250-
if (branchEvent.EventId.Value.SequenceEqual(eventId))
255+
if (m_branches != null)
251256
{
252-
alarm = branchEvent;
253-
break;
257+
foreach (ConditionState branchEvent in m_branches.Values)
258+
{
259+
if (branchEvent.EventId.Value.SequenceEqual(eventId))
260+
{
261+
return branchEvent;
262+
}
263+
}
254264
}
255265
}
256266

257-
return alarm;
267+
return null;
258268
}
259269

260270
/// <summary>
@@ -267,10 +277,12 @@ protected void ReplaceBranchEvent(byte[] originalEventId, ConditionState alarm)
267277
string originalKey = Utils.ToHexString(originalEventId);
268278
string newKey = Utils.ToHexString(alarm.EventId.Value);
269279

270-
Dictionary<string, ConditionState> branches = GetBranches();
271-
272-
branches.Remove(originalKey);
273-
branches.Add(newKey, alarm);
280+
lock (m_branchesLock)
281+
{
282+
m_branches ??= [];
283+
m_branches.Remove(originalKey);
284+
m_branches.Add(newKey, alarm);
285+
}
274286
}
275287

276288
/// <summary>
@@ -281,18 +293,21 @@ protected void RemoveBranchEvent(byte[] eventId)
281293
{
282294
string key = Utils.ToHexString(eventId);
283295

284-
Dictionary<string, ConditionState> branches = GetBranches();
285-
286-
branches.Remove(key);
296+
lock (m_branchesLock)
297+
{
298+
m_branches?.Remove(key);
299+
}
287300
}
288301

289302
/// <summary>
290303
/// Clear all branches for this event
291304
/// </summary>
292305
public void ClearBranches()
293306
{
294-
Dictionary<string, ConditionState> branches = GetBranches();
295-
branches.Clear();
307+
lock (m_branchesLock)
308+
{
309+
m_branches?.Clear();
310+
}
296311
}
297312

298313
/// <summary>
@@ -344,9 +359,10 @@ protected virtual bool GetRetainState()
344359
/// </returns>
345360
public virtual int GetBranchCount()
346361
{
347-
Dictionary<string, ConditionState> branches = GetBranches();
348-
349-
return branches.Count;
362+
lock (m_branchesLock)
363+
{
364+
return m_branches?.Count ?? 0;
365+
}
350366
}
351367

352368
/// <summary>
@@ -797,6 +813,11 @@ protected bool IsBranch()
797813
/// Branches
798814
/// </summary>
799815
protected Dictionary<string, ConditionState> m_branches;
816+
817+
/// <summary>
818+
/// Lock protecting all access to <see cref="m_branches"/>.
819+
/// </summary>
820+
protected readonly Lock m_branchesLock = new();
800821
private PropertyState<bool> m_supportsFilteredRetain;
801822
}
802823

Stack/Opc.Ua.Types/State/NodeStateCollection.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,29 @@ public AliasToUse(string alias, NodeId nodeId)
178178
/// </summary>
179179
public void SaveAsNodeSet2(ISystemContext context, Stream ostrm)
180180
{
181-
SaveAsNodeSet2(context, ostrm, null);
181+
SaveAsNodeSet2(context, ostrm, null, null);
182182
}
183183

184184
/// <summary>
185185
/// Writes the collection to a stream using the Opc.Ua.Schema.UANodeSet schema.
186186
/// </summary>
187187
public void SaveAsNodeSet2(ISystemContext context, Stream ostrm, string version)
188+
{
189+
SaveAsNodeSet2(context, ostrm, version, null);
190+
}
191+
192+
/// <summary>
193+
/// Writes the collection to a stream using the Opc.Ua.Schema.UANodeSet schema.
194+
/// </summary>
195+
/// <param name="context">The system context.</param>
196+
/// <param name="ostrm">The output stream.</param>
197+
/// <param name="version">The version string to embed in the NodeSet2 XML, or <c>null</c>.</param>
198+
/// <param name="lastModified">The last-modified timestamp to embed, or <c>null</c> to use <see cref="DateTime.UtcNow"/>.</param>
199+
public void SaveAsNodeSet2(ISystemContext context, Stream ostrm, string version, DateTime? lastModified)
188200
{
189201
var nodeSet = new Export.UANodeSet
190202
{
191-
LastModified = DateTime.UtcNow,
203+
LastModified = lastModified ?? DateTime.UtcNow,
192204
LastModifiedSpecified = true
193205
};
194206

Tests/Opc.Ua.Client.Tests/NodeSetExportTest.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* http://opcfoundation.org/License/MIT/1.00/
2828
* ======================================================================*/
2929

30+
using System;
3031
using System.Collections.Generic;
3132
using System.IO;
3233
using System.Linq;
@@ -335,6 +336,10 @@ public async Task ExportNodesToNodeSet2_DefaultOptionsAsync()
335336

336337
Assert.Greater(allNodes.Count, 0, "Should have found at least one node");
337338

339+
// Use a fixed timestamp so both exports have identical LastModified fields,
340+
// preventing flakiness from sub-second precision differences.
341+
var lastModified = DateTime.UtcNow;
342+
338343
// Export with default options
339344
string tempFile = Path.GetTempFileName();
340345
try
@@ -347,7 +352,7 @@ public async Task ExportNodesToNodeSet2_DefaultOptionsAsync()
347352
ServerUris = Session.ServerUris
348353
};
349354

350-
CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Default);
355+
CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Default, lastModified);
351356
}
352357

353358
// Read it back and verify values are not exported
@@ -380,20 +385,16 @@ public async Task ExportNodesToNodeSet2_DefaultOptionsAsync()
380385
ServerUris = Session.ServerUris
381386
};
382387

383-
CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Complete);
388+
CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Complete, lastModified);
384389
}
385390

386391
var completeFile = new FileInfo(tempFileComplete);
387392
long completeSize = completeFile.Length;
388393

389394
// Default should be smaller or equal to complete
390395
// (Equal if nodes don't have values to export)
391-
if (completeSize < defaultSize)
392-
{
393-
TestContext.AddTestAttachment(tempFileComplete, "Complete export file");
394-
TestContext.AddTestAttachment(tempFile, "Default export file");
395-
Assert.LessOrEqual(defaultSize, completeSize, "Default export should not be larger than Complete");
396-
}
396+
Assert.That(defaultSize, Is.LessThanOrEqualTo(completeSize),
397+
"Default export should not be larger than Complete");
397398
}
398399
finally
399400
{

Tests/Opc.Ua.Client.Tests/SessionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,7 @@ public async Task OpenAsyncShouldOpenSessionSuccessfullyAsync()
12401240
{
12411241
Results =
12421242
[
1243-
new (new Variant(0u))
1243+
new (new Variant(0))
12441244
],
12451245
DiagnosticInfos = []
12461246
}))

Tests/Opc.Ua.Core.Tests/Stack/State/ConditionStateTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,64 @@ public void DerivedClassCanOverrideEvaluateRetainStateOnEnable()
328328
Assert.That(condition.Retain.Value, Is.True); // Custom logic always sets to true
329329
}
330330

331+
/// <summary>
332+
/// Test that the branch collection can be safely created, enumerated and
333+
/// cleared from multiple threads concurrently without throwing.
334+
/// Reproduces the race that previously crashed the process when the
335+
/// branch dictionary was enumerated while being modified.
336+
/// </summary>
337+
[Test]
338+
public void ConcurrentBranchAccessIsThreadSafe()
339+
{
340+
var condition = new ConditionState(null);
341+
condition.Create(m_context, new NodeId(1), "Condition", null, true);
342+
// Ensure each created branch receives a unique EventId.
343+
condition.AutoReportStateChanges = true;
344+
condition.SetEnableState(m_context, true);
345+
346+
const int iterations = 2000;
347+
using var cts = new System.Threading.CancellationTokenSource();
348+
349+
// Writer: continuously creates and clears branches.
350+
System.Threading.Tasks.Task writer = System.Threading.Tasks.Task.Run(() =>
351+
{
352+
for (int i = 0; i < iterations; i++)
353+
{
354+
condition.CreateBranch(m_context, new NodeId((uint)(i + 2)));
355+
if (i % 10 == 0)
356+
{
357+
condition.ClearBranches();
358+
}
359+
}
360+
});
361+
362+
// Readers: continuously enumerate and count branches.
363+
System.Threading.Tasks.Task reader = System.Threading.Tasks.Task.Run(() =>
364+
{
365+
while (!cts.Token.IsCancellationRequested)
366+
{
367+
foreach (System.Collections.Generic.KeyValuePair<string, ConditionState> branch
368+
in condition.GetBranches())
369+
{
370+
Assert.That(branch.Value, Is.Not.Null);
371+
}
372+
373+
_ = condition.GetBranchCount();
374+
}
375+
});
376+
377+
try
378+
{
379+
writer.GetAwaiter().GetResult();
380+
}
381+
finally
382+
{
383+
cts.Cancel();
384+
}
385+
386+
Assert.That(reader.Wait(System.TimeSpan.FromSeconds(5)), Is.True, "Reader task did not stop after cancellation.");
387+
}
388+
331389
/// <summary>
332390
/// Test condition that exposes method to force Retain value for testing.
333391
/// </summary>

0 commit comments

Comments
 (0)