From b2e8686af133ea82794a7809c49734bfda3730a3 Mon Sep 17 00:00:00 2001 From: keeleycenc <844123814@qq.com> Date: Wed, 15 Jul 2026 13:49:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20MC=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=AE=8B=E5=B8=A7=E8=AF=AF=E5=88=A4=E5=AF=BC=E8=87=B4=20TCP=20?= =?UTF-8?q?=E6=B5=81=E9=94=99=E4=BD=8D=E4=B8=8E=E5=8D=8A=E5=BC=80=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=97=A0=E6=B3=95=E8=87=AA=E6=84=88=E7=9A=84=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 根因 现场日志 “ReadErrorException: Not enough data read from PLC. Expected at least 4 bytes, but got 0 bytes” 主要不是掉线,而是收发/解析 链路的几处缺陷叠加,导致 TCP 流错位(desync)且坏连接不失效: 1. ExecuteAsync 的读循环用「单次读到 < 缓冲区长度就 break」判断消息边界。 TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达,于是会拿到残帧。 2. MitsubishiHelper.IsIncorrectResponse 把「长度不足 minLength」误判为 「正确」(return false)放行,残帧因此被当作有效响应,不再重试。 3. McCommand.SetResponse 在帧短于帧头时直接 break,静默产出空 Response, 最终在 ReadWordsAsync 变成 “got 0 bytes”。 4. 读到 0 字节(对端关闭/EOF)或传输异常时未失效内部连接,CheckConnection() 仍返回 true,OpenAsync/TryReconnectAsync 开头的 `if (CheckConnection()) return;` 短路而不重连,复用坏 socket。 残帧只读走半截,socket 缓冲区残留的脏字节让之后每次读取都错位,故障持续; 只有 ProbeConnectionAsync 因 finally 里 Disconnect() 销毁并重建 socket 才恢复。 这正是「一旦发生、重启一直报,只有测试连接后才恢复」的原因。 ## 修复 1. 新增 McFrameReader:按协议帧结构读满整帧——先按定长读满帧头 (MC1E=2 / MC3E=11 / MC4E=15),再由帧头「响应数据长度」字段算出剩余字节 并用 ReadExactly 读够整帧;MC1E 响应头无长度字段,按请求推算数据长度。 读到 0 字节即判定对端关闭(EOF)→ 抛 ConnectionException;取消/超时抛 OperationCanceledException,二者严格区分,取消不当断线。 ExecuteAsync 改用该读帧逻辑,彻底消除分段导致的残帧与流错位。 2. 修正 IsIncorrectResponse:长度不足帧头判为「不正确」(return true), 不再把残帧当正确响应放行。 3. SetResponse:帧长不足帧头时抛出明确的 ReadErrorException,不再静默返回空数据。 4. 事务出错即失效连接:ExecuteAsync 在 EOF/传输异常时 InvalidateConnection(), 使 CheckConnection() 随后返回 false,下次操作重连,避免脏字节错位蔓延。 并为 IsSocketAlive 的 Poll + Available 半开检测补充说明性注释。 三种帧(MC1E/MC3E/MC4E)均覆盖读满整帧逻辑。对外异常语义保持不变 (读写路径仍以 ReadErrorException/WriteErrorException/ConnectionException 呈现)。 版本号升至 1.1.1(修订版)。 ## 测试 新增 McFrameReaderTest:验证 MC3E/MC4E/MC1E 响应在逐字节等极端 TCP 分段下 仍能正确拼接为完整帧、数据段中途 EOF 判为断线、取消不误判为断线。 40 通过 / 28 跳过(无实机)/ 0 失败;dotnet build 四目标框架 0 警告 0 错误。 Co-Authored-By: Claude Opus 4.8 --- src/MAS.Communication.csproj | 6 +- src/McProtocol/Commands/McCommand.cs | 10 +- src/McProtocol/Helpers/McFrameReader.cs | 113 ++++++++++++++ src/McProtocol/Helpers/MitsubishiHelper.cs | 4 +- src/McProtocol/McProtocolClient.cs | 74 ++++----- tests/McProtocol/McFrameReaderTest.cs | 173 +++++++++++++++++++++ 6 files changed, 329 insertions(+), 51 deletions(-) create mode 100644 src/McProtocol/Helpers/McFrameReader.cs create mode 100644 tests/McProtocol/McFrameReaderTest.cs diff --git a/src/MAS.Communication.csproj b/src/MAS.Communication.csproj index 3dc8356..4039352 100644 --- a/src/MAS.Communication.csproj +++ b/src/MAS.Communication.csproj @@ -7,9 +7,9 @@ True ../../build/APIDocumentation/MAS.Communication.xml - 1.1.0 - 1.1.0.0 - 1.1.0.0 + 1.1.1 + 1.1.1.0 + 1.1.1.0 MAS(厦门威光)研发部 MAS(厦门威光) Copyright (c) MAS(厦门威光) Corporation. All rights reserved. diff --git a/src/McProtocol/Commands/McCommand.cs b/src/McProtocol/Commands/McCommand.cs index afbe585..b20dceb 100644 --- a/src/McProtocol/Commands/McCommand.cs +++ b/src/McProtocol/Commands/McCommand.cs @@ -112,7 +112,10 @@ public int SetResponse(byte[] response) { case McFrame.MC1E: min = 2; if (min > response.Length) { - break; + // 帧长不足帧头即为残帧,抛出明确异常而不是静默返回空 Response, + // 避免上层把「不完整响应」误当成「读到 0 字节数据」。 + throw new ReadErrorException( + $"Incomplete MC1E response frame. Expected at least {min} bytes, but got {response.Length} bytes."); } _resultCode = response[min - 2]; @@ -129,7 +132,10 @@ public int SetResponse(byte[] response) { case McFrame.MC4E: min = FrameType == McFrame.MC3E ? 11 : 15; if (min > response.Length) { - break; + // 帧长不足帧头即为残帧,抛出明确异常而不是静默返回空 Response, + // 避免上层把「不完整响应」误当成「读到 0 字节数据」。 + throw new ReadErrorException( + $"Incomplete MC response frame. Expected at least {min} bytes, but got {response.Length} bytes."); } var btCount = new[] { response[min - 4], response[min - 3] }; diff --git a/src/McProtocol/Helpers/McFrameReader.cs b/src/McProtocol/Helpers/McFrameReader.cs new file mode 100644 index 0000000..267fea4 --- /dev/null +++ b/src/McProtocol/Helpers/McFrameReader.cs @@ -0,0 +1,113 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.McProtocol; + +/// +/// MC 协议响应帧读取帮助类 +/// +/// +/// TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达。此处按协议帧结构读满整帧: +/// 先读满定长帧头,再根据帧头中的「响应数据长度」字段读够剩余字节, +/// 避免用单次读的长度判断消息边界而拿到残帧、进而让后续读取永久错位(desync)。 +/// +internal static class McFrameReader { + /// + /// 获取指定帧类型的响应帧头长度(字节) + /// + /// + /// MC1E 响应帧头为「子头(1) + 结束代码(1)」共 2 字节; + /// MC3E 为 11 字节、MC4E 为 15 字节,两者帧头内均含结束代码与响应数据长度字段。 + /// + public static int GetResponseHeaderLength(McFrame frameType) { + return frameType switch { + McFrame.MC1E => 2, + McFrame.MC3E => 11, + McFrame.MC4E => 15, + _ => throw new Exception("Message frame not supported."), + }; + } + + /// + /// 从流中读取一帧完整的 MC 响应 + /// + /// 网络流 + /// 协议帧类型 + /// + /// MC1E 正常响应的预期数据字节数(由请求推算)。MC1E 响应头没有长度字段, + /// 只能依据请求推算数据长度;MC3E/MC4E 从帧头的长度字段计算,忽略此参数。 + /// + /// 取消令牌 + /// 完整的响应帧字节数组 + /// 读到 0 字节(对端已关闭连接/EOF)时抛出 + /// 读取被取消或超时 + public static async Task ReadResponseFrameAsync( + Stream stream, + McFrame frameType, + int expectedDataLength, + CancellationToken cts = default) { + + int headerLength = GetResponseHeaderLength(frameType); + + // 先按定长读满帧头,避免 TCP 分段导致的残帧 + byte[] header = await ReadExactlyAsync(stream, headerLength, cts).ConfigureAwait(false); + + int remaining = frameType switch { + // MC3E/MC4E 帧头末尾为「响应数据长度」字段,其值 = 结束代码(2) + 数据字节数, + // 结束代码已包含在帧头内,故仍需读取的数据字节数为「长度字段值 - 2」。 + McFrame.MC3E or McFrame.MC4E => + (header[headerLength - 4] | (header[headerLength - 3] << 8)) - 2, + // MC1E 响应头无长度字段:结束代码(第 2 字节)为 0 表示正常,随后为预期数据; + // 非 0 表示异常,随后固定为 1 字节异常代码。 + McFrame.MC1E => header[1] == 0x00 ? expectedDataLength : 1, + _ => throw new Exception("Message frame not supported."), + }; + + if (remaining <= 0) { + return header; + } + + byte[] body = await ReadExactlyAsync(stream, remaining, cts).ConfigureAwait(false); + + byte[] frameBytes = new byte[headerLength + remaining]; + Buffer.BlockCopy(header, 0, frameBytes, 0, headerLength); + Buffer.BlockCopy(body, 0, frameBytes, headerLength, remaining); + return frameBytes; + } + + /// + /// 从流中精确读取指定字节数 + /// + /// + /// 循环读取直至读满 字节。读到 0 字节表示对端已关闭连接(EOF), + /// 判定为断线并抛出 ;读取被取消/超时则抛出 + /// ,两者语义严格区分,取消不当作断线。 + /// + private static async Task ReadExactlyAsync(Stream stream, int count, CancellationToken cts) { + byte[] buffer = new byte[count]; + int offset = 0; + + while (offset < count) { +#if NETFRAMEWORK + int read = await stream.ReadAsync(buffer, offset, count - offset, cts).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset), cts).ConfigureAwait(false); +#endif + if (read == 0) { + // 读到 0 字节说明对端已关闭连接(TCP 进入半开/EOF 状态),判定为断线。 + throw new ConnectionException("The connection has been closed by the remote host."); + } + + offset += read; + } + + return buffer; + } +} diff --git a/src/McProtocol/Helpers/MitsubishiHelper.cs b/src/McProtocol/Helpers/MitsubishiHelper.cs index f424eb9..0a19bb6 100644 --- a/src/McProtocol/Helpers/MitsubishiHelper.cs +++ b/src/McProtocol/Helpers/MitsubishiHelper.cs @@ -78,8 +78,10 @@ public static PlcDeviceType ParsePlcDeviceType(string deviceType) { /// 协议类型 /// 如果响应数据不正确,返回 true;否则返回 false public static bool IsIncorrectResponse(McFrame frameType, byte[] response, int minLength) { + // 响应长度不足一个帧头即为残帧(不完整),必须判为「不正确」; + // 早前误将其判为「正确」放行,导致 TCP 分段产生的残帧被当作有效数据。 if (response.Length < minLength) { - return false; + return true; } switch (frameType) { diff --git a/src/McProtocol/McProtocolClient.cs b/src/McProtocol/McProtocolClient.cs index 3ff0c78..67287e8 100644 --- a/src/McProtocol/McProtocolClient.cs +++ b/src/McProtocol/McProtocolClient.cs @@ -8,9 +8,6 @@ // See LICENSE file in the project root for full license information. // ============================================================================= -#if !NETFRAMEWORK -using System.Buffers; -#endif using System.Net.Sockets; namespace MAS.Communication.McProtocol; @@ -323,6 +320,9 @@ private bool IsSocketAlive() { return false; } + // socket.Connected 反映的是最近一次收发时的状态,对半开连接并不可靠; + // 这里再用 Poll + Available 识别对端已关闭的情况:Poll 返回可读且 + // Available == 0,说明收到了对端的 FIN(连接已断),判定为不可用。 if (socket.Poll(0, SelectMode.SelectRead) && socket.Available == 0) { return false; } @@ -389,8 +389,9 @@ private async Task WriteDeviceBlockAsync(PlcDeviceType type, int address, i _ => throw new Exception("Message frame not supported"), }; + // 写请求的响应仅为确认帧(无数据部分),MC1E 预期数据长度为 0。 byte[] rtResponse = await TryExecutionAsync( - sdCommand, length, cts: cts + sdCommand, length, expectedDataLength: 0, cts: cts ).ConfigureAwait(false); return mcCommand.SetResponse(rtResponse); @@ -405,8 +406,10 @@ private async Task ReadDeviceBlockAsync(PlcDeviceType type, int address, _ => throw new Exception("Message frame not supported"), }; + // 读请求响应含数据部分。MC1E 响应头无长度字段,需按请求推算数据字节数 + // (与 MC3E/MC4E 一致,返回 size 个点、每点 2 字节);MC3E/MC4E 会改用帧头长度字段。 byte[] rtResponse = await TryExecutionAsync( - sdCommand, length, cts: cts + sdCommand, length, expectedDataLength: size * 2, cts: cts ).ConfigureAwait(false); _ = mcCommand.SetResponse(rtResponse); @@ -418,6 +421,7 @@ private async Task ReadDeviceBlockAsync(PlcDeviceType type, int address, private async Task TryExecutionAsync( byte[] command, int minlength, + int expectedDataLength, int maxRetries = 10, TimeSpan? retryDelay = null, CancellationToken cts = default) { @@ -426,7 +430,9 @@ private async Task TryExecutionAsync( int retryCount = 0; do { - response = await ExecuteAsync(command, cts).ConfigureAwait(false); + // ExecuteAsync 已按帧结构读满整帧;若对端关闭/传输异常会抛出并失效连接, + // 这里得到的始终是一帧完整响应(长度不小于帧头)。 + response = await ExecuteAsync(command, expectedDataLength, cts).ConfigureAwait(false); retryCount++; if (MitsubishiHelper.IsIncorrectResponse(_config.ProtocolFrame, response, minlength)) { @@ -443,43 +449,29 @@ private async Task TryExecutionAsync( return response; } - private async Task ExecuteAsync(byte[] command, CancellationToken cts = default) { - if (_stream == null) { + private async Task ExecuteAsync(byte[] command, int expectedDataLength, CancellationToken cts = default) { + if (_stream is not NetworkStream stream) { throw new InvalidOperationException("The network flow is uninitialized"); } await _streamLock.WaitAsync(cts).ConfigureAwait(false); try { - await WriteToStreamAsync(_stream, command, cts).ConfigureAwait(false); - await FlushStreamAsync(_stream, cts).ConfigureAwait(false); - - using MemoryStream memoryStream = new(); - -#if NETFRAMEWORK - byte[] buffer = new byte[256]; -#else - byte[] buffer = ArrayPool.Shared.Rent(256); -#endif + await WriteToStreamAsync(stream, command, cts).ConfigureAwait(false); + await FlushStreamAsync(stream, cts).ConfigureAwait(false); - try { - int bytesRead; - while ((bytesRead = await ReadFromStreamAsync(_stream, buffer, cts).ConfigureAwait(false)) > 0) { - memoryStream.Write(buffer, 0, bytesRead); - if (bytesRead < buffer.Length) { - break; - } - } - - if (memoryStream.Length == 0) { - throw new Exception("The connection has been disconnected"); - } - - return memoryStream.ToArray(); - } finally { -#if !NETFRAMEWORK - ArrayPool.Shared.Return(buffer); -#endif - } + // TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达。必须按协议帧结构读满 + // 整帧,不能用单次读的长度判断消息边界,否则会拿到残帧并让后续读取永久错位。 + return await McFrameReader.ReadResponseFrameAsync( + stream, _config.ProtocolFrame, expectedDataLength, cts).ConfigureAwait(false); + } catch (OperationCanceledException) { + // 读写超时或被主动取消不属于断线,保持内部连接状态不变,直接向上传播。 + throw; + } catch { + // 发送/接收阶段出现 EOF 或 IO/Socket 等传输异常,均视为连接已失效: + // 复位内部连接状态后再向上传播,确保随后重连时能真正重建连接, + // 也避免残帧留下的脏字节让之后每次读取都错位、故障蔓延。 + InvalidateConnection(); + throw; } finally { _ = _streamLock.Release(); } @@ -493,14 +485,6 @@ private static Task WriteToStreamAsync(NetworkStream stream, byte[] buffer, Canc #endif } - private static Task ReadFromStreamAsync(NetworkStream stream, byte[] buffer, CancellationToken cts) { -#if NETFRAMEWORK - return stream.ReadAsync(buffer, 0, buffer.Length, cts); -#else - return stream.ReadAsync(buffer, cts).AsTask(); -#endif - } - private static Task FlushStreamAsync(NetworkStream stream, CancellationToken cts) { #if NETFRAMEWORK return stream.FlushAsync(); diff --git a/tests/McProtocol/McFrameReaderTest.cs b/tests/McProtocol/McFrameReaderTest.cs new file mode 100644 index 0000000..ffb2117 --- /dev/null +++ b/tests/McProtocol/McFrameReaderTest.cs @@ -0,0 +1,173 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication; +using MAS.Communication.McProtocol; + +namespace MAS.CommunicationUnitTest.McProtocol; + +/// +/// McFrameReader 读帧逻辑单元测试(不依赖真实 PLC)。 +/// 重点验证:TCP 分段到达的响应能被按帧结构正确拼接为完整帧, +/// 以及读到 0 字节(对端关闭)时判定为断线、取消不被误判为断线。 +/// +[TestClass] +public class McFrameReaderTest { + /// + /// 构造一帧完整的 MC3E/MC4E 响应:定长帧头(含数据长度字段与结束代码)+ 数据。 + /// + private static byte[] BuildSelfDescribingFrame(McFrame frame, byte[] data) { + int headerLength = McFrameReader.GetResponseHeaderLength(frame); + int rsCount = data.Length + 2; // 数据长度字段 = 结束代码(2) + 数据 + + byte[] frameBytes = new byte[headerLength + data.Length]; + + // 子头 + frameBytes[0] = frame == McFrame.MC3E ? (byte)0xD0 : (byte)0xD4; + + // 数据长度字段位于帧头末尾第 4、3 字节 + frameBytes[headerLength - 4] = (byte)rsCount; + frameBytes[headerLength - 3] = (byte)(rsCount >> 8); + + // 结束代码 0x0000(正常)位于帧头末尾第 2、1 字节 + frameBytes[headerLength - 2] = 0x00; + frameBytes[headerLength - 1] = 0x00; + + Buffer.BlockCopy(data, 0, frameBytes, headerLength, data.Length); + return frameBytes; + } + + [TestMethod] + [DataRow(McFrame.MC3E, 1)] + [DataRow(McFrame.MC3E, 2)] + [DataRow(McFrame.MC3E, 3)] + [DataRow(McFrame.MC3E, 7)] + [DataRow(McFrame.MC3E, 256)] + [DataRow(McFrame.MC4E, 1)] + [DataRow(McFrame.MC4E, 2)] + [DataRow(McFrame.MC4E, 5)] + [DataRow(McFrame.MC4E, 256)] + public async Task 分段到达的自描述帧能被正确拼接为完整帧(McFrame frame, int chunkSize) { + byte[] data = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + byte[] expected = BuildSelfDescribingFrame(frame, data); + + using var stream = new ChunkedReadStream(expected, chunkSize); + byte[] actual = await McFrameReader.ReadResponseFrameAsync(stream, frame, expectedDataLength: 0); + + CollectionAssert.AreEqual(expected, actual, + $"帧类型 {frame} 在每次最多读 {chunkSize} 字节的分段下未能正确拼接完整帧"); + } + + [TestMethod] + [DataRow(1)] + [DataRow(2)] + [DataRow(3)] + [DataRow(256)] + public async Task 分段到达的MC1E正常帧能按预期数据长度拼接完整(int chunkSize) { + // MC1E 响应头:子头(0x80) + 结束代码(0x00 正常),随后为预期数据字节 + byte[] data = [0xAA, 0xBB, 0xCC, 0xDD]; + byte[] expected = [0x80, 0x00, .. data]; + + using var stream = new ChunkedReadStream(expected, chunkSize); + byte[] actual = await McFrameReader.ReadResponseFrameAsync( + stream, McFrame.MC1E, expectedDataLength: data.Length); + + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public async Task MC1E异常帧读取1字节异常代码() { + // 结束代码非 0 时,随后固定为 1 字节异常代码,与预期数据长度无关 + byte[] expected = [0x80, 0x51, 0xAB]; + + using var stream = new ChunkedReadStream(expected, 1); + byte[] actual = await McFrameReader.ReadResponseFrameAsync( + stream, McFrame.MC1E, expectedDataLength: 100); + + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public async Task 读到0字节判定为断线并抛出连接异常() { + // 帧头尚未读满即 EOF(对端关闭) + byte[] partial = [0xD0, 0x00, 0x00, 0xFF]; // 不足 MC3E 的 11 字节帧头 + + using var stream = new ChunkedReadStream(partial, 8); + _ = await Assert.ThrowsExactlyAsync(async () => + await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0)); + } + + [TestMethod] + public async Task 数据段中途EOF判定为断线并抛出连接异常() { + // 帧头完整、声明还有 4 字节数据,但数据只到达 2 字节即 EOF + byte[] frame = BuildSelfDescribingFrame(McFrame.MC3E, [0x11, 0x22, 0x33, 0x44]); + byte[] truncated = frame[..(frame.Length - 2)]; + + using var stream = new ChunkedReadStream(truncated, 8); + _ = await Assert.ThrowsExactlyAsync(async () => + await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0)); + } + + [TestMethod] + public async Task 取消令牌触发时抛出取消异常而非连接异常() { + byte[] frame = BuildSelfDescribingFrame(McFrame.MC3E, [0x11, 0x22]); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + using var stream = new ChunkedReadStream(frame, 4); + _ = await Assert.ThrowsExactlyAsync(async () => + await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0, cts.Token)); + } + + /// + /// 模拟 TCP 分段的只读流:每次 Read 最多返回 chunkSize 字节,数据耗尽后返回 0(EOF)。 + /// + private sealed class ChunkedReadStream(byte[] data, int chunkSize) : Stream { + private readonly byte[] _data = data; + private readonly int _chunkSize = chunkSize; + private int _position; + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + + int available = _data.Length - _position; + if (available <= 0) { + return ValueTask.FromResult(0); + } + + int toCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), available); + _data.AsMemory(_position, toCopy).CopyTo(buffer); + _position += toCopy; + return ValueTask.FromResult(toCopy); + } + + public override int Read(byte[] buffer, int offset, int count) { + int available = _data.Length - _position; + if (available <= 0) { + return 0; + } + + int toCopy = Math.Min(Math.Min(_chunkSize, count), available); + Array.Copy(_data, _position, buffer, offset, toCopy); + _position += toCopy; + return toCopy; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _data.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +}