Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2a7aba5
Fix client session, managed session and subscription defects from the…
romanett Sep 11, 2026
928f74a
Fix subscription manager, node cache and browser defects from the audit
romanett Sep 11, 2026
c245332
Fix the file system, alias name, user management and web API client d…
romanett Sep 11, 2026
34f3201
Correct and cite the spec basis for the client audit fixes
romanett Sep 11, 2026
bbc1e4f
Fix the regressions the review of the client audit fixes found
romanett Sep 11, 2026
a62006c
Harden the pool teardown and the WaitAsync polyfill
romanett Sep 11, 2026
d296397
Let the managed browse ride out a continuation point starvation run
romanett Sep 11, 2026
813384d
Address the lifecycle and concurrency findings from the PR review
romanett Sep 11, 2026
735bb74
Narrow the absorbed browse failures and order the bind replay properly
romanett Sep 11, 2026
0d5b9c4
Back off between fruitless browse passes and scope the tracker cleanup
romanett Sep 11, 2026
6bf5572
Keep the reverse connection until the gate grants, and fix the wrap gap
romanett Sep 12, 2026
4d94f5b
Fix the CA1816 and CA2000 analyzer warnings in the managed session
romanett Sep 13, 2026
935f5d8
Stop the node cache subtype filter from blocking the caller
romanett Sep 13, 2026
ed3f062
Address review: session factory engine, release logging, dispose path
romanett Sep 13, 2026
ef26d08
Let ComplexTypeSystem own the resolver the client factories create
romanett Sep 13, 2026
69b1c93
Sync the bundled migration guide copy of sessions-subscriptions.md
romanett Sep 13, 2026
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
64 changes: 37 additions & 27 deletions src/Opc.Ua.Client/AliasNames/AliasNameClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,35 +445,10 @@ public async IAsyncEnumerable<AliasNameSubCategoryInfo>
throw new ServiceResultException(br.StatusCode);
}

foreach (ReferenceDescription r in SnapshotReferences(br.References))
{
if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType))
{
continue;
}
var localId = ExpandedNodeId.ToNodeId(
r.NodeId, Session.NamespaceUris);
if (localId.IsNull)
{
continue;
}
yield return new AliasNameSubCategoryInfo(
localId,
r.BrowseName,
r.DisplayName);
}

ByteString continuationPoint = br.ContinuationPoint;
while (!continuationPoint.IsEmpty)
try
{
(_, continuationPoint, ArrayOf<ReferenceDescription> nextReferences) =
await Session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoint: false,
continuationPoint,
ct).ConfigureAwait(false);

foreach (ReferenceDescription r in SnapshotReferences(nextReferences))
foreach (ReferenceDescription r in SnapshotReferences(br.References))
{
if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType))
{
Expand All @@ -490,6 +465,41 @@ await Session.BrowseNextAsync(
r.BrowseName,
r.DisplayName);
}

while (!continuationPoint.IsEmpty)
{
(_, continuationPoint, ArrayOf<ReferenceDescription> nextReferences) =
await Session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoint: false,
continuationPoint,
ct).ConfigureAwait(false);

foreach (ReferenceDescription r in SnapshotReferences(nextReferences))
{
if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType))
{
continue;
}
var localId = ExpandedNodeId.ToNodeId(
r.NodeId, Session.NamespaceUris);
if (localId.IsNull)
{
continue;
}
yield return new AliasNameSubCategoryInfo(
localId,
r.BrowseName,
r.DisplayName);
}
}
}
finally
{
// Part 4 §5.9.3.2 requires releasing a continuation point the
// client stops following.
await Session.ReleaseContinuationPointAsync(continuationPoint)
.ConfigureAwait(false);
}
}

Expand Down
39 changes: 35 additions & 4 deletions src/Opc.Ua.Client/AliasNames/AliasNameResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ public AliasNameResolver(
/// </summary>
public async Task EnsureLoadedAsync(CancellationToken ct = default)
{
if (Volatile.Read(ref m_loaded) == 1)
if (Volatile.Read(ref m_loadedGeneration) ==
Interlocked.Read(ref m_invalidationGeneration))
{
return;
}
Expand Down Expand Up @@ -135,6 +136,11 @@ await m_strategy.StartAsync(Client, Invalidate, ct)
/// </summary>
public async Task RefreshAsync(CancellationToken ct = default)
{
// Capture the invalidation generation before the fetch: an
// Invalidate that lands while it is in flight must not be undone by
// marking the (already stale) result as loaded.
long generation = Interlocked.Read(ref m_invalidationGeneration);

var forward = new Dictionary<string, ExpandedNodeId[]>(StringComparer.Ordinal);
var serverUris = new Dictionary<string, string?[]>(StringComparer.Ordinal);
var reverse = new Dictionary<ExpandedNodeId, string>();
Expand Down Expand Up @@ -179,7 +185,14 @@ await Client.FindAliasAsync("%", NodeId.Null, ct)
m_forward = forward;
m_serverUris = serverUris;
m_reverse = reverse;
Volatile.Write(ref m_loaded, 1);

// Publish the generation this data was fetched for rather than
// a separate "loaded" flag: an Invalidate that lands between a
// check and the flag write would otherwise be overwritten and
// mark pre-invalidation data as current forever. A reader
// compares the two generations, so the single write below can
// never swallow an invalidation.
Volatile.Write(ref m_loadedGeneration, generation);
}
finally
{
Expand Down Expand Up @@ -290,7 +303,11 @@ public async Task<IReadOnlyList<ExpandedNodeId>> ResolveAsync(
/// </summary>
public void Invalidate()
{
Volatile.Write(ref m_loaded, 0);
// One atomic step: a refresh that is already fetching publishes the
// generation it fetched for, which no longer matches this one, so
// its stale result can neither be served nor swallow this
// invalidation.
Interlocked.Increment(ref m_invalidationGeneration);
}

/// <summary>
Expand Down Expand Up @@ -412,7 +429,21 @@ private Dictionary<string, ExpandedNodeId[]> m_forward
= new(StringComparer.Ordinal);

private Dictionary<ExpandedNodeId, string> m_reverse = [];
private int m_loaded;

/// <summary>
/// The <see cref="m_invalidationGeneration"/> the cached data was
/// fetched for. The cache is loaded exactly while this equals the
/// current generation; <c>-1</c> is "never loaded", which no
/// generation can collide with.
/// </summary>
private long m_loadedGeneration = -1;

/// <summary>
/// Incremented by every <see cref="Invalidate"/> so an in-flight
/// <see cref="RefreshAsync"/> can tell whether its result is still
/// current when it completes.
/// </summary>
private long m_invalidationGeneration;
private int m_strategyStarted;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,38 +91,89 @@ public async ValueTask StartAsync(
return;
}

Subscription subscription;
Subscription? subscription = null;
bool ownsSubscription = false;
if (Options.SharedSubscription != null)
MonitoredItem? item = null;
try
{
subscription = Options.SharedSubscription;
}
else
{
subscription = new Subscription(client.Session.MessageContext.Telemetry)
if (Options.SharedSubscription != null)
{
subscription = Options.SharedSubscription;
}
else
{
subscription = new Subscription(client.Session.MessageContext.Telemetry)
{
DisplayName = Options.SubscriptionDisplayName,
PublishingEnabled = true,
PublishingInterval = (int)Options.PublishingIntervalMs
};
client.Session.AddSubscription(subscription);

// Claim ownership before the first await that can fail:
// CreateAsync throwing must still tear the registration
// down, otherwise the subscription this cleanup exists to
// prevent is exactly what a failed start leaves behind.
ownsSubscription = true;
await subscription.CreateAsync(ct).ConfigureAwait(false);
}

item = new MonitoredItem(subscription.DefaultItem)
{
DisplayName = Options.SubscriptionDisplayName,
PublishingEnabled = true,
PublishingInterval = (int)Options.PublishingIntervalMs
StartNodeId = lastChangeId,
AttributeId = Attributes.Value,
DisplayName = "LastChange",
SamplingInterval = (int)Options.SamplingIntervalMs,
QueueSize = 1,
DiscardOldest = true,
MonitoringMode = MonitoringMode.Reporting
};
client.Session.AddSubscription(subscription);
await subscription.CreateAsync(ct).ConfigureAwait(false);
ownsSubscription = true;
item.Notification += OnNotification;
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct).ConfigureAwait(false);
}

var item = new MonitoredItem(subscription.DefaultItem)
catch
{
StartNodeId = lastChangeId,
AttributeId = Attributes.Value,
DisplayName = "LastChange",
SamplingInterval = (int)Options.SamplingIntervalMs,
QueueSize = 1,
DiscardOldest = true,
MonitoringMode = MonitoringMode.Reporting
};
item.Notification += OnNotification;
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct).ConfigureAwait(false);
// Do not leave a half-built subscription behind: the caller
// retries StartAsync on every resolve, and each failed attempt
// would otherwise add another subscription to the session.
if (item != null && subscription != null)
{
item.Notification -= OnNotification;
try
{
subscription.RemoveItem(item);
}
catch (Exception cleanupException)
when (cleanupException is not OutOfMemoryException)
{
// Best-effort cleanup.
}
}
if (ownsSubscription && subscription != null)
{
try
{
// Cleanup must run to completion even when the caller's
// token is what aborted the start.
await client.Session
.RemoveSubscriptionAsync(subscription, CancellationToken.None)
.ConfigureAwait(false);
await subscription.DeleteAsync(silent: true, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception cleanupException)
when (cleanupException is not OutOfMemoryException)
{
// Best-effort cleanup; the session may already be gone.
}
finally
{
subscription.Dispose();
}
}
throw;
}

m_subscription = subscription;
m_item = item;
Expand Down
Loading
Loading