review(Driver.OpcUaClient): release browse continuation point on cancel

Re-review at 7286d320. -016: BrowseRecursiveAsync now releases the server-side continuation
point on OperationCanceledException (BrowseNext releaseContinuationPoints:true) before
rethrowing (resolves the Browser-002 cross-cutting leak) + TDD.
This commit is contained in:
Joseph Doherty
2026-06-19 11:47:11 -04:00
parent 04e0877bff
commit be272d960f
3 changed files with 302 additions and 7 deletions
@@ -934,11 +934,34 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var continuationPoint = result.ContinuationPoint;
while (continuationPoint is { Length: > 0 })
{
var next = await session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoints: false,
continuationPoints: [continuationPoint],
ct: ct).ConfigureAwait(false);
BrowseNextResponse next;
try
{
next = await session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoints: false,
continuationPoints: [continuationPoint],
ct: ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Caller cancelled mid-pagination. Release the server-side cursor so it
// doesn't hold resources until session close (Driver.OpcUaClient-016;
// same pattern as Driver.OpcUaClient.Browser-002). Use CancellationToken.None
// for the release — the original token is already cancelled so we must not
// gate the release on it. Fire-and-forget: if the server is also unreachable,
// the release fails silently and the cursor times out server-side anyway.
try
{
await session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoints: true,
continuationPoints: [continuationPoint],
ct: CancellationToken.None).ConfigureAwait(false);
}
catch { /* best-effort — server may have already cleaned up */ }
throw;
}
if (next.Results.Count == 0) break;
var nextResult = next.Results[0];
@@ -947,6 +970,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
continuationPoint = nextResult.ContinuationPoint;
}
}
catch (OperationCanceledException)
{
// Propagate cancellation — don't silently swallow it like a transient browse
// failure. The caller's CancellationToken was honoured; let it observe that.
throw;
}
catch
{
// Transient browse failure on a sub-tree — don't kill the whole discovery, just