FOCAS Tier-C PR A — Driver.FOCAS.Shared MessagePack IPC contracts. First PR of the 5-PR #220 split (isolation plan at docs/v2/implementation/focas-isolation-plan.md). Adds a new netstandard2.0 project consumable by both the .NET 10 Proxy and the future .NET 4.8 x86 Host, carrying every wire DTO the Proxy <-> Host pair will exchange: Hello/HelloAck + Heartbeat/HeartbeatAck + ErrorResponse for session negotiation (shared-secret + protocol major/minor mirroring Galaxy.Shared); OpenSessionRequest/Response + CloseSessionRequest carrying the declared FocasCncSeries so the Host picks up the pre-flight matrix; FocasAddressDto + FocasDataTypeCode for wire-compatible serialization of parsed addresses (0=Pmc/1=Param/2=Macro matches FocasAreaKind enum order so both sides cast (int)); ReadRequest/Response + WriteRequest/Response with MessagePack-serialized boxed values tagged by FocasDataTypeCode; PmcBitWriteRequest/Response as a first-class RMW operation so the critical section stays Host-side; Subscribe/Unsubscribe/OnDataChangeNotification for poll-loop-pushes-deltas model (FOCAS has no CNC-initiated callbacks); Probe + RuntimeStatusChange + Recycle surface for Tier-C supervision. Framing is [4-byte BE length][1-byte kind][body] with 16 MiB body cap matching Galaxy; FocasMessageKind byte values align with Galaxy ranges so an operator reading a hex dump doesn't have to context-switch. FrameReader/FrameWriter ported from Galaxy.Shared with thread-safe concurrent-write serialization. 24 new unit tests: 18 per-DTO round-trip tests covering every field + 6 framing tests (single-frame round-trip, clean-EOF returns null, oversized-length rejection, mid-frame EOF throws, 20-way concurrent-write ordering preserved, MessageKind byte values locked as wire-stable). No driver changes; existing 165 FOCAS unit tests still pass unchanged. PR B (Host skeleton) goes next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 13:55:35 -04:00
parent 4a6fe7fa7e
commit e6ff39148b
14 changed files with 949 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
/// <summary>
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ContractRoundTripTests
{
private static T RoundTrip<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value);
return MessagePackSerializer.Deserialize<T>(bytes);
}
[Fact]
public void Hello_round_trips()
{
var original = new Hello
{
ProtocolMajor = 1,
ProtocolMinor = 2,
PeerName = "OtOpcUa.Server",
SharedSecret = "abc-123",
Features = ["bulk-read", "pmc-rmw"],
};
var decoded = RoundTrip(original);
decoded.ProtocolMajor.ShouldBe(1);
decoded.ProtocolMinor.ShouldBe(2);
decoded.PeerName.ShouldBe("OtOpcUa.Server");
decoded.SharedSecret.ShouldBe("abc-123");
decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
}
[Fact]
public void HelloAck_rejected_carries_reason()
{
var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
var decoded = RoundTrip(original);
decoded.Accepted.ShouldBeFalse();
decoded.RejectReason.ShouldBe("bad secret");
}
[Fact]
public void Heartbeat_and_ack_preserve_ticks()
{
var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
hb.MonotonicTicks.ShouldBe(987654321);
var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
ack.MonotonicTicks.ShouldBe(987654321);
ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void ErrorResponse_preserves_code_and_message()
{
var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
decoded.Code.ShouldBe("Fwlib32Crashed");
decoded.Message.ShouldBe("EW_UNEXPECTED");
}
[Fact]
public void OpenSessionRequest_preserves_series_and_timeout()
{
var decoded = RoundTrip(new OpenSessionRequest
{
HostAddress = "192.168.1.50:8193",
TimeoutMs = 3500,
CncSeries = 5,
});
decoded.HostAddress.ShouldBe("192.168.1.50:8193");
decoded.TimeoutMs.ShouldBe(3500);
decoded.CncSeries.ShouldBe(5);
}
[Fact]
public void OpenSessionResponse_failure_carries_error_code()
{
var decoded = RoundTrip(new OpenSessionResponse
{
Success = false,
SessionId = 0,
Error = "unreachable",
ErrorCode = "EW_SOCKET",
});
decoded.Success.ShouldBeFalse();
decoded.Error.ShouldBe("unreachable");
decoded.ErrorCode.ShouldBe("EW_SOCKET");
}
[Fact]
public void FocasAddressDto_carries_pmc_with_bit_index()
{
var decoded = RoundTrip(new FocasAddressDto
{
Kind = 0,
PmcLetter = "R",
Number = 100,
BitIndex = 3,
});
decoded.Kind.ShouldBe(0);
decoded.PmcLetter.ShouldBe("R");
decoded.Number.ShouldBe(100);
decoded.BitIndex.ShouldBe(3);
}
[Fact]
public void FocasAddressDto_macro_omits_letter_and_bit()
{
var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
decoded.Kind.ShouldBe(2);
decoded.PmcLetter.ShouldBeNull();
decoded.Number.ShouldBe(500);
decoded.BitIndex.ShouldBeNull();
}
[Fact]
public void ReadRequest_and_response_round_trip()
{
var req = RoundTrip(new ReadRequest
{
SessionId = 42,
Address = new FocasAddressDto { Kind = 1, Number = 1815 },
DataType = FocasDataTypeCode.Int32,
TimeoutMs = 1500,
});
req.SessionId.ShouldBe(42);
req.Address.Number.ShouldBe(1815);
req.DataType.ShouldBe(FocasDataTypeCode.Int32);
var resp = RoundTrip(new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize((int)12345),
ValueTypeCode = FocasDataTypeCode.Int32,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
});
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
MessagePackSerializer.Deserialize<int>(resp.ValueBytes!).ShouldBe(12345);
resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
}
[Fact]
public void WriteRequest_and_response_round_trip()
{
var req = RoundTrip(new WriteRequest
{
SessionId = 1,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Float64,
ValueBytes = MessagePackSerializer.Serialize(3.14159),
ValueTypeCode = FocasDataTypeCode.Float64,
});
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14159);
var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
}
[Fact]
public void PmcBitWriteRequest_preserves_bit_and_value()
{
var req = RoundTrip(new PmcBitWriteRequest
{
SessionId = 7,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
BitIndex = 5,
Value = true,
});
req.BitIndex.ShouldBe(5);
req.Value.ShouldBeTrue();
}
[Fact]
public void SubscribeRequest_round_trips_multiple_items()
{
var original = new SubscribeRequest
{
SessionId = 1,
SubscriptionId = 100,
IntervalMs = 250,
Items =
[
new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
],
};
var decoded = RoundTrip(original);
decoded.Items.Length.ShouldBe(2);
decoded.Items[0].MonitoredItemId.ShouldBe(1);
decoded.Items[0].Address.PmcLetter.ShouldBe("R");
decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
}
[Fact]
public void SubscribeResponse_rejected_items_survive()
{
var decoded = RoundTrip(new SubscribeResponse
{
Success = true,
RejectedMonitoredItemIds = [2, 7],
});
decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
}
[Fact]
public void UnsubscribeRequest_round_trips()
{
var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
decoded.SubscriptionId.ShouldBe(42);
}
[Fact]
public void OnDataChangeNotification_round_trips()
{
var original = new OnDataChangeNotification
{
SubscriptionId = 100,
Changes =
[
new()
{
MonitoredItemId = 1,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize(true),
ValueTypeCode = FocasDataTypeCode.Bit,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
},
],
};
var decoded = RoundTrip(original);
decoded.Changes.Length.ShouldBe(1);
MessagePackSerializer.Deserialize<bool>(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
}
[Fact]
public void ProbeRequest_and_response_round_trip()
{
var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
req.TimeoutMs.ShouldBe(500);
var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
resp.Healthy.ShouldBeTrue();
resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void RuntimeStatusChangeNotification_round_trips()
{
var decoded = RoundTrip(new RuntimeStatusChangeNotification
{
SessionId = 5,
RuntimeStatus = "Stopped",
ObservedAtUtcUnixMs = 1_700_000_000_000,
});
decoded.RuntimeStatus.ShouldBe("Stopped");
}
[Fact]
public void RecycleHostRequest_and_response_round_trip()
{
var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
req.Kind.ShouldBe("Hard");
req.Reason.ShouldBe("wedge-detected");
var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
resp.Accepted.ShouldBeTrue();
resp.GraceSeconds.ShouldBe(20);
}
}

View File

@@ -0,0 +1,107 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class FramingTests
{
[Fact]
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
}
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
hello.PeerName.ShouldBe("proxy");
hello.SharedSecret.ShouldBe("s3cr3t");
}
[Fact]
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
{
using var empty = new MemoryStream();
using var reader = new FrameReader(empty, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldBeNull();
}
[Fact]
public async Task FrameReader_throws_on_oversized_length_prefix()
{
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
using var stream = new MemoryStream(hostile);
using var reader = new FrameReader(stream, leaveOpen: true);
await Should.ThrowAsync<InvalidDataException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameReader_throws_on_mid_frame_eof()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
TestContext.Current.CancellationToken);
}
// Truncate so body is incomplete.
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
using var partial = new MemoryStream(truncated);
using var reader = new FrameReader(partial, leaveOpen: true);
await Should.ThrowAsync<EndOfStreamException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameWriter_serializes_concurrent_writes()
{
var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = i },
TestContext.Current.CancellationToken)).ToArray();
await Task.WhenAll(tasks);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var seen = new List<long>();
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
{
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
}
seen.Count.ShouldBe(20);
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
}
[Fact]
public void MessageKind_values_are_stable()
{
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>