fix(client): release a capped HistoryRead cursor, read (not release) follow-up pages, detach deleted subscriptions
R08 1.4: raw and modified history reads share HistoryReadPaging. A read stopped by the caller's cap releases the server continuation point. The raw path also passed releaseContinuationPoints = (cp != null), which released page two instead of reading it, so every raw read was silently truncated to its first server page. R08 1.5: DefaultSubscriptionAdapter removes its Subscription from the SDK Session on delete/dispose (and a failed CreateAsync removes it too), so re-targeted alarm subscriptions no longer accumulate in Session.Subscriptions. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -164,46 +164,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
ReturnBounds = false
|
||||
};
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
};
|
||||
|
||||
var allValues = new List<DataValue>();
|
||||
byte[]? continuationPoint = null;
|
||||
|
||||
do
|
||||
{
|
||||
if (continuationPoint != null)
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
|
||||
// Use the async overload so this method is genuinely asynchronous,
|
||||
// honors the cancellation token, and does not block the caller's thread
|
||||
// (which would block the UI dispatcher for client.ui consumers).
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null,
|
||||
new ExtensionObject(details),
|
||||
TimestampsToReturn.Source,
|
||||
continuationPoint != null,
|
||||
nodesToRead,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var results = response.Results;
|
||||
if (results == null || results.Count == 0)
|
||||
break;
|
||||
|
||||
var result = results[0];
|
||||
// A refused read (BadHistoryOperationUnsupported, BadUserAccessDenied, ...) must surface; only
|
||||
// BadNoData is an empty result. Swallowing it printed "0 values returned" for a refusal.
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
|
||||
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
} while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
|
||||
|
||||
return allValues;
|
||||
// Continuation pages are READ, never released, until the server finishes or the cap is reached; a read stopped
|
||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
||||
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||
// silently truncating every raw read to its first server page.
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
||||
? historyData.DataValues
|
||||
: [],
|
||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -219,59 +189,41 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
ReturnBounds = false
|
||||
};
|
||||
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||
ToModifiedEntries, maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||
{
|
||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
yield return new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Issues one single-node HistoryRead (or a release of its continuation point).</summary>
|
||||
private async Task<HistoryReadResult?> ReadHistoryPageAsync(
|
||||
ReadRawModifiedDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||
TimestampsToReturn timestamps, CancellationToken ct)
|
||||
{
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
||||
};
|
||||
|
||||
var all = new List<Models.HistoryModifiedEntry>();
|
||||
byte[]? continuationPoint = null;
|
||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||
// client.ui consumers).
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null, new ExtensionObject(details), timestamps, release, nodesToRead, ct).ConfigureAwait(false);
|
||||
|
||||
do
|
||||
{
|
||||
if (continuationPoint != null)
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null,
|
||||
new ExtensionObject(details),
|
||||
TimestampsToReturn.Both,
|
||||
false,
|
||||
nodesToRead,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var results = response.Results;
|
||||
if (results == null || results.Count == 0)
|
||||
break;
|
||||
|
||||
var result = results[0];
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
|
||||
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryModifiedData modified)
|
||||
{
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
all.Add(new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName));
|
||||
}
|
||||
}
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
} while (continuationPoint != null && continuationPoint.Length > 0 && all.Count < maxValues);
|
||||
|
||||
// A read stopped by the client's own cap leaves a server cursor behind; release it.
|
||||
if (continuationPoint is { Length: > 0 })
|
||||
{
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
await _session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
|
||||
nodesToRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return all;
|
||||
return response.Results is { Count: > 0 } results ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -327,9 +279,22 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
};
|
||||
|
||||
_session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
try
|
||||
{
|
||||
await subscription.CreateAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A subscription the server never created must not linger in Session.Subscriptions either.
|
||||
try { await _session.RemoveSubscriptionAsync(subscription, CancellationToken.None); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error removing a subscription that failed to create"); }
|
||||
throw;
|
||||
}
|
||||
|
||||
return new DefaultSubscriptionAdapter(subscription);
|
||||
// Deleting the adapter also removes the subscription from the SDK session, so a re-targeted alarm/data
|
||||
// subscription does not accumulate dead Subscription objects in Session.Subscriptions.
|
||||
return new DefaultSubscriptionAdapter(subscription,
|
||||
async (s, token) => await _session.RemoveSubscriptionAsync(s, token).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user