Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
Joseph Doherty 3892555631 FOCAS Tier-C PR C — IPC path end-to-end: Proxy IpcFocasClient + Host FwlibFrameHandler + IFocasBackend abstraction. Third of 5 PRs for #220. Ships the wire path from IFocasClient calls in the .NET 10 driver, over a named-pipe (or in-memory stream) to the .NET 4.8 Host's FwlibFrameHandler, dispatched to an IFocasBackend. Keeps the existing IFocasClient DI seam intact so existing unit tests are unaffected (172/172 still pass). Proxy side adds Ipc/FocasIpcClient (owns one pipe stream + call gate so concurrent callers don't interleave frames, supports both real NamedPipeClientStream and arbitrary Stream for in-memory test loopback) and Ipc/IpcFocasClient (implements IFocasClient by forwarding every call as an IPC frame — Connect sends OpenSessionRequest and caches the SessionId; Read sends ReadRequest and decodes the typed value via FocasDataTypeCode; Write sends WriteRequest for non-bit data or PmcBitWriteRequest when it's a PMC bit so the RMW critical section stays on the Host; Probe sends ProbeRequest; Dispose best-effort sends CloseSessionRequest); plus FocasIpcException surfacing Host-side ErrorResponse frames as typed exceptions. Host side adds Backend/IFocasBackend (the Host's view of one FOCAS session — Open/Close/Read/Write/PmcBitWrite/Probe) with two implementations: FakeFocasBackend (in-memory, per-address value store, honors bit-write RMW semantics against the containing byte — used by tests and as an OTOPCUA_FOCAS_BACKEND=fake operational stub) and UnconfiguredFocasBackend (structured failure pointing at docs/v2/focas-deployment.md — the safe default when OTOPCUA_FOCAS_BACKEND is unset or hardware isn't configured). Ipc/FwlibFrameHandler replaces StubFrameHandler: deserializes each request DTO, delegates to the IFocasBackend, re-serializes into the matching response kind. Catches backend exceptions and surfaces them as ErrorResponse{backend-exception} rather than tearing down the pipe. Program.cs now picks the backend from OTOPCUA_FOCAS_BACKEND env var (fake/unconfigured/fwlib32; fwlib32 still maps to Unconfigured because the real Fwlib32 P/Invoke integration is a hardware-dependent follow-up — #220 captures it). Tests: 7 new IPC round-trip tests on the Proxy side (IpcFocasClient vs. an IpcLoopback fake server: connect happy path, connect rejection, read decode, write round-trip, PMC bit write routes to first-class RMW frame, probe, ErrorResponse surfaces as typed exception) + 6 new Host-side tests on FwlibFrameHandler (OpenSession allocates id, read-without-session fails, full open/write/read round-trip preserves value, PmcBitWrite sets the specified bit, Probe reports healthy with open session, UnconfiguredBackend returns pointed-at-docs error with ErrorCode=NoFwlibBackend). Existing 165 FOCAS unit tests + 24 Shared tests + 3 Host handshake tests all unchanged. Total post-PR: 172+24+9 = 205 FOCAS-family tests green. What's NOT in this PR: the actual Fwlib32.dll P/Invoke integration inside the Host (FwlibHostedBackend) lands as a hardware-dependent follow-up since no CNC is available for validation; supervisor + respawn + crash-loop breaker comes in PR D; MMF + NSSM install scripts in PR E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:10:52 -04:00

121 lines
4.7 KiB
C#

using System.IO;
using System.IO.Pipes;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
/// <summary>
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. Owns the pipe connection
/// and serializes request/response round-trips through a single call gate so
/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
/// </summary>
public sealed class FocasIpcClient : IAsyncDisposable
{
private readonly Stream _stream;
private readonly FrameReader _reader;
private readonly FrameWriter _writer;
private readonly SemaphoreSlim _callGate = new(1, 1);
private FocasIpcClient(Stream stream)
{
_stream = stream;
_reader = new FrameReader(stream, leaveOpen: true);
_writer = new FrameWriter(stream, leaveOpen: true);
}
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
public static async Task<FocasIpcClient> ConnectAsync(
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
{
var stream = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
}
/// <summary>
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
/// until <see cref="DisposeAsync"/>.
/// </summary>
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
=> HandshakeAsync(stream, sharedSecret, ct);
private static async Task<FocasIpcClient> HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
{
var client = new FocasIpcClient(stream);
try
{
await client._writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
if (!ackMsg.Accepted)
throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
return client;
}
catch
{
await client.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public async Task<TResp> CallAsync<TReq, TResp>(
FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
{
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
{
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
throw new FocasIpcException(err.Code, err.Message);
}
if (frame.Value.Kind != expectedResponseKind)
throw new InvalidOperationException(
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
}
finally { _callGate.Release(); }
}
public async Task SendOneWayAsync<TReq>(FocasMessageKind requestKind, TReq request, CancellationToken ct)
{
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
finally { _callGate.Release(); }
}
public async ValueTask DisposeAsync()
{
_callGate.Dispose();
_reader.Dispose();
_writer.Dispose();
await _stream.DisposeAsync().ConfigureAwait(false);
}
}
public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
{
public string Code { get; } = code;
}