fix(abcip): budget expected reply size in RequestScheduler.TakeBatch (#582); private lock + struct-handle check across read fragments (#567)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:53:10 -04:00
parent 04b6563286
commit 9641cefe2e
8 changed files with 187 additions and 16 deletions
@@ -437,4 +437,48 @@ public sealed class RequestSchedulerTests
ch.Sent[1][^1].ShouldBe((byte)2);
FakeCipChannel.SplitMsp(ch.Sent[2]).Select(e => e[^1]).ShouldBe(new byte[] { 3, 4 });
}
[Fact]
public async Task Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget()
{
// Every request is 8 bytes, so the REQUEST budget would pack all of them into one MSP; only the
// expected-reply budget (#582) splits the batch. Reply envelope = 6 + 2n + sum(replies) <= 504.
const int scalar = 8, str = 96, array = 200;
var ch = new FakeCipChannel(maxRequestBytes: 504);
await using var s = new RequestScheduler(ch, allowPacking: true);
var a = await HoldFirstAsync(s, ch, Req(8, 1));
var sizes = new[] { scalar, str, str, str, str, array, scalar, RequestScheduler.UnknownReply };
var tasks = sizes.Select((size, i) => s.SendAsync(Req(8, (byte)(10 + i)), Ct, expectedReplyBytes: size)).ToArray();
ch.Gate!.SetResult();
await Task.WhenAll(tasks.Append(a)).WaitAsync(Timeout, Ct);
// [sc, S, S, S, S] = 6+10+392 = 408; the array (+200) would make 608 > 504, so it starts batch 2
// with the trailing scalar; the unknown-size read goes alone.
ch.Sent.Count.ShouldBe(4);
FakeCipChannel.SplitMsp(ch.Sent[1]).Select(e => e[^1]).ShouldBe(new byte[] { 10, 11, 12, 13, 14 });
FakeCipChannel.SplitMsp(ch.Sent[2]).Select(e => e[^1]).ShouldBe(new byte[] { 15, 16 });
IsMsp(ch.Sent[3]).ShouldBeFalse();
ch.Sent[3][^1].ShouldBe((byte)17);
ReplyEnvelopeFor(scalar, str, str, str, str).ShouldBeLessThanOrEqualTo(504);
ReplyEnvelopeFor(scalar, str, str, str, str, array).ShouldBeGreaterThan(504);
}
[Fact]
public async Task Status_only_replies_still_pack_to_the_request_limit()
{
// Positive control for the reply budget: the default (status-only) reply never splits a batch
// the request budget allows, so existing write / probe coalescing is unchanged.
var ch = new FakeCipChannel(maxRequestBytes: 504);
await using var s = new RequestScheduler(ch, allowPacking: true);
var a = await HoldFirstAsync(s, ch, Req(8, 1));
var rest = Enumerable.Range(0, 20).Select(i => s.SendAsync(Req(8, (byte)(2 + i)), Ct)).ToArray();
ch.Gate!.SetResult();
await Task.WhenAll(rest.Append(a)).WaitAsync(Timeout, Ct);
ch.Sent.Count.ShouldBe(2);
FakeCipChannel.SplitMsp(ch.Sent[1]).Count.ShouldBe(20);
}
private static int ReplyEnvelopeFor(params int[] replies) => RequestScheduler.ReplyEnvelopeSize(replies);
}
@@ -279,4 +279,29 @@ public sealed class LogixTagClientTests
() => client.WriteAsync(Path, LogixTypeCode.DInt, null, 40, Bytes(160, 0), Ct));
ch.Sent.Count.ShouldBe(1);
}
[Fact]
public async Task A_struct_handle_that_changes_between_fragments_fails_the_read()
{
var (ch, client, s) = Create();
await using var _ = s;
ch.RespondPerService = r => ReadOffset(r) == 0
? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.Struct, Bytes(8, 0), handle: 0x0FCE))
: FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.Struct, Bytes(4, 8), handle: 0x1234));
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 1, Ct));
ex.Status.ShouldBe(AbStatus.BadReply);
ex.Message.ShouldContain("0x0FCE");
ex.Message.ShouldContain("0x1234");
}
[Fact]
public void Expected_read_reply_bytes_budgets_elements_times_width_and_is_unknown_without_a_width()
{
// header 4 + type/handle 4 + 3 STRING elements.
LogixTagClient.ExpectedReadReplyBytes(3, LogixTypeCode.StringElementBytes, 0).ShouldBe(4 + 4 + 3 * 88);
LogixTagClient.ExpectedReadReplyBytes(3, LogixTypeCode.StringElementBytes, 100).ShouldBe(4 + 4 + 3 * 88 - 100);
LogixTagClient.ExpectedReadReplyBytes(3, 0, 0).ShouldBe(RequestScheduler.UnknownReply);
}
}