From 8aed9ac365e3c7374fd2651bbfb0164131ddce39 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:57:08 -0400 Subject: [PATCH] fix(r2-01): RED framing-fault classification Theory T5 (STAB-15a, task 6) --- ...2-01-s7-fault-hardening-plan.md.tasks.json | 2 +- .../S7DriverReconnectTests.cs | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json index 536f17d4..bb19fed9 100644 --- a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json @@ -40,7 +40,7 @@ { "id": 6, "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL", - "status": "pending", + "status": "completed", "blockedBy": [0] }, { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs index d39ae618..ab9ffdf8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs @@ -247,6 +247,50 @@ public sealed class S7DriverReconnectTests async () => await drv.ReadAsync(["W0"], cts.Token)); } + // ---- STAB-15a: ISO-on-TCP framing/desync faults must reopen ---- + + /// + /// T5 (STAB-15a). A framing/desync fault on ISO-on-TCP (S7.Net's + /// / / + /// , or a carrying + /// ) leaves the stream position + /// untrustworthy — only a reopen recovers. The next read must dispose connection #1 and + /// open a fresh #2. Before the fix these were not classified fatal, so the desynced handle + /// was reused forever (Created.Count pinned at 1). + /// + [Theory] + [InlineData("tpkt")] + [InlineData("tpdu")] + [InlineData("wrongbytes")] + [InlineData("plc-wrongnumber")] + public async Task Framing_faults_are_classified_connection_fatal_and_reopen(string kind) + { + Exception framing = kind switch + { + "tpkt" => new TPKTInvalidException(), + "tpdu" => new TPDUInvalidException(), + "wrongbytes" => new WrongNumberOfBytesException(), + "plc-wrongnumber" => new PlcException(ErrorCode.WrongNumberReceivedBytes, "short read"), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + var factory = new FakeS7PlcFactory(); + using var drv = new S7Driver(Options(), "s7-framing", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Read #1 hits the framing fault — Bad status, handle marked dead, but no reopen yet. + factory.Created[0].NextReadThrows = framing; + var bad = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); + bad[0].StatusCode.ShouldNotBe(0u); + factory.Created.Count.ShouldBe(1); + + // Read #2 reopens a fresh connection and reads Good. + var recovered = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); + recovered[0].StatusCode.ShouldBe(0u); + factory.Created.Count.ShouldBe(2); + factory.Created[0].Disposed.ShouldBeTrue(); + } + // ---- fakes ---- private sealed class FakeS7PlcFactory : IS7PlcFactory