fix(opcua): fit the raw end bound against the page's effective cap

H1 fuzz finding (routed to Lane B because AppendEndBoundAsync lives in
OtOpcUaNodeManager.cs): a bounded HistoryRead-Raw page sized BELOW
NumValuesPerNode by the backend's row ceiling (RawPageLimit.Plan) took
the end bound whenever its data fitted, because the fit was judged
against NumValuesPerNode instead of the page's effective cap — a full
ceiling-sized page came back with cap + 1 values. The bound is now
fitted against the page allowance (the ceiling-adjusted data cap plus
the start bound a first page already carries); when the page is full
the bound goes to the next page through the continuation point.

Test: NodeManagerHistoryReadBoundsTests
.A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page
(ceiling 4 ⇒ 3 data rows; first page = start bound + 3 with a CP, second
page = the end bound alone; control: no ceiling ⇒ one page of 5).

Refs #732 #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:43:21 -04:00
parent d90f35b6be
commit 8a1116e658
2 changed files with 44 additions and 6 deletions
@@ -6781,13 +6781,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The data is drained — the end bound ends this page when it has room (Part 11: bounds count toward
// NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any.
async Task<byte[]?> AppendEndBoundAsync(List<DataValueSnapshot> page, DateTime resumedAtUtc)
// `pageAllowance` is the EFFECTIVE cap of this page — NumValuesPerNode, less whatever the backend's row
// ceiling (RawPageLimit) took off it, plus the start bound a first page already carries — not
// NumValuesPerNode itself: against N a ceiling-sized page that was full still took the bound, returning
// cap + 1 values (#736 fuzz). 0 = unbounded.
async Task<byte[]?> AppendEndBoundAsync(List<DataValueSnapshot> page, DateTime resumedAtUtc, uint pageAllowance)
{
// The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this
// page's last, else the last of an earlier page (carried in the cursor), else where the read resumed.
var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null)
?? endBound!.LastReturnedUtc ?? resumedAtUtc;
if (numValuesPerNode == 0 || page.Count < numValuesPerNode)
if (pageAllowance == 0 || page.Count < pageAllowance)
{
page.Add(await HistoryBounds.EndBoundAsync(
HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false));
@@ -6832,7 +6836,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
&& (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution)))
{
var onlyBound = new List<DataValueSnapshot>(1);
var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false);
var afterCp = await AppendEndBoundAsync(onlyBound, startUtc, numValuesPerNode).ConfigureAwait(false);
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.Good,
@@ -7052,7 +7056,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
else if (endBound is not null)
{
// The data is drained — the end bound ends this page, or is the next one when this one is full.
clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false);
clusterCp = await AppendEndBoundAsync(slice, startUtc, readCap).ConfigureAwait(false);
}
results[handle.Index] = new SdkHistoryReadResult
@@ -7105,7 +7109,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The data is drained — the end bound ends this page, or is the next one when this one is full.
var page = new List<DataValueSnapshot>(samples.Count + 1);
page.AddRange(samples);
outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false);
// This page's allowance: the (ceiling-adjusted) data cap, plus the start bound it already leads with.
var allowance = readCap == 0 ? 0u : readCap + (startBound is not null ? 1u : 0u);
outboundCp = await AppendEndBoundAsync(page, startUtc, allowance).ConfigureAwait(false);
samples = page;
historyData = ToHistoryDataFromSamples(samples);
}