diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md index 85f69cd..3ecf97d 100644 --- a/docs/drivers/FOCAS-Test-Fixture.md +++ b/docs/drivers/FOCAS-Test-Fixture.md @@ -69,14 +69,32 @@ covers the common address shapes; per-model quirks are not stressed. - Parameter range enforcement (CNC rejects out-of-range writes) - MTB (machine tool builder) custom screens that expose non-standard data -### 5. Tier-C process isolation behavior +### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated -Per driver-stability.md, FOCAS should run process-isolated because -`Fwlib32.dll` has documented crash modes. The test suite runs in-process + -only exercises the happy path + mapped error codes — a native access -violation from the DLL would take the test host down. The process-isolation -path (similar to Galaxy's out-of-process Host) has been scoped but not -implemented. +The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS +PR A–E, task #220): + +- `Driver.FOCAS.Shared` carries MessagePack IPC contracts +- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts + a connection on a strictly-ACL'd named pipe + dispatches frames to + an `IFocasBackend` +- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI + seam by forwarding over IPC — swap the DI registration and the + driver runs Tier-C with zero other changes +- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn + + heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert +- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔ + `Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the + last ~1000 IPC operations survives a Host crash + +The one remaining gap is the production `FwlibHostedBackend`: an +`IFocasBackend` implementation that wraps the licensed +`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we +need a CNC on the bench (or the licensed FANUC developer kit DLL +with a test harness) to validate it. Until then, the Host ships +`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting +`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole +Tier-C pipeline end-to-end without any CNC. ## When to trust FOCAS tests, when to reach for a rig diff --git a/docs/v2/implementation/focas-isolation-plan.md b/docs/v2/implementation/focas-isolation-plan.md index cca41ff..ecb6eb2 100644 --- a/docs/v2/implementation/focas-isolation-plan.md +++ b/docs/v2/implementation/focas-isolation-plan.md @@ -1,12 +1,13 @@ # FOCAS Tier-C isolation — plan for task #220 -> **Status**: DRAFT — not yet started. Tracks the multi-PR work to -> move `Fwlib32.dll` behind an out-of-process host, mirroring the -> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md). +> **Status**: PRs A–E shipped. Architecture is in place; the only +> remaining FOCAS work is the hardware-dependent production +> integration of `Fwlib32.dll` into a real `IFocasBackend` +> (`FwlibHostedBackend`), which needs an actual CNC on the bench +> and is tracked as a follow-up on #220. > -> **Pre-reqs shipped** (this PR): version matrix + pre-flight -> validation + unit tests. Those close the cheap half of the -> hardware-free stability gap. Tier-C closes the expensive half. +> **Pre-reqs shipped**: version matrix + pre-flight validation +> (PR #168 — the cheap half of the hardware-free stability gap). ## Why isolate @@ -79,32 +80,41 @@ its own timer + pushes change notifications so the Proxy doesn't round-trip per poll. Matches `Driver.Galaxy.Host` subscription forwarding. -## PR sequence (proposed) +## PR sequence — shipped -1. **PR A — shared contracts** - Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No - behaviour change. ~200 LOC + round-trip tests for each DTO. -2. **PR B — Host project skeleton** - Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper, - pipe server scaffold with the same ACL + caller-SID + shared - secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns - `NotImplemented` for everything. ~400 LOC. -3. **PR C — Move Fwlib32 calls into Host** - Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`, - `FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards - over IPC. This is the biggest PR — probably 800-1500 LOC of - move-with-translation. Existing unit tests keep passing because - `IFocasTagFactory` is the DI seam the tests inject against. -4. **PR D — Supervisor + respawn** - Proxy-side heartbeat + respawn + crash-loop circuit breaker + - BackPressure fan-out on Host death. ~500 LOC + chaos tests. -5. **PR E — Post-mortem MMF + operational glue** - MMF writer in Host, reader in Proxy. Install scripts for the - new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC. +1. **PR A (#169) — shared contracts** ✅ + `Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every + IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/ + Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/ + FrameWriter + 24 round-trip tests. +2. **PR B (#170) — Host project skeleton** ✅ + `Driver.FOCAS.Host` net48 x86 Windows Service entry point, + `PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`. + ACL denies LocalSystem/Administrators; Hello verifies + shared-secret + protocol major. 3 handshake tests. +3. **PR C (#171) — IPC path end-to-end** ✅ + Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements + IFocasClient via IPC). Host `Backend/IFocasBackend` + + `FakeFocasBackend` + `UnconfiguredFocasBackend` + + `Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip + tests via in-memory loopback. +4. **PR D (#172) — Supervisor + respawn** ✅ + `Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min → + 1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` + + `FocasHostSupervisor`. 14 tests. +5. **PR E — Ops glue** ✅ (this PR) + `ProcessHostLauncher` (real Process.Start + FocasIpcClient + connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') + + Proxy `Supervisor/PostMortemReader`, `scripts/install/ + Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers. + 7 tests (4 MMF round-trip + 3 reader format compatibility). -Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy -Tier-C but narrower since FOCAS has no Historian + no alarm -history. +**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.** + +What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke +into a real `FwlibHostedBackend` implementation of `IFocasBackend` ++ validating against a live CNC. The architecture is all the +plumbing that work needs. ## Testing without hardware diff --git a/scripts/install/Install-FocasHost.ps1 b/scripts/install/Install-FocasHost.ps1 new file mode 100644 index 0000000..b81e376 --- /dev/null +++ b/scripts/install/Install-FocasHost.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Registers the OtOpcUaFocasHost Windows service. Optional companion to + Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run + with Tier-C process isolation enabled. + +.DESCRIPTION + FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86) + as a Windows service using NSSM, running under the same service account as the main + OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via + environment variable at service-start time so it never hits disk. + +.PARAMETER InstallRoot + Where the FOCAS Host binaries live (typically + C:\Program Files\OtOpcUa\Driver.FOCAS.Host). + +.PARAMETER ServiceAccount + Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the + PipeAcl match succeeds. + +.PARAMETER FocasSharedSecret + Per-process secret passed via env var. Generated freshly per install if not supplied. + +.PARAMETER FocasBackend + Backend selector for the Host process. One of: + fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH) + fake (in-memory; smoke-test mode) + unconfigured (safe default returning structured errors; use until hardware is wired) + +.PARAMETER FocasPipeName + Pipe name the Host listens on. Default: OtOpcUaFocas. + +.EXAMPLE + .\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' ` + -ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$InstallRoot, + [Parameter(Mandatory)] [string]$ServiceAccount, + [string]$FocasSharedSecret, + [ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured', + [string]$FocasPipeName = 'OtOpcUaFocas', + [string]$ServiceName = 'OtOpcUaFocasHost', + [string]$NssmPath = 'C:\Program Files\nssm\nssm.exe' +) + +$ErrorActionPreference = 'Stop' + +function Resolve-Sid { + param([string]$Account) + if ($Account -match '^S-\d-\d+') { return $Account } + try { + $nt = New-Object System.Security.Principal.NTAccount($Account) + return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name." + } +} + +if (-not (Test-Path $NssmPath)) { + throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath." +} + +$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe' +if (-not (Test-Path $hostExe)) { + throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first." +} + +if (-not $FocasSharedSecret) { + $FocasSharedSecret = [System.Guid]::NewGuid().ToString('N') + Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config." +} + +$allowedSid = Resolve-Sid $ServiceAccount + +# Idempotent install — remove + re-create if present. +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host "Removing existing '$ServiceName' service..." + & $NssmPath stop $ServiceName confirm | Out-Null + & $NssmPath remove $ServiceName confirm | Out-Null +} + +& $NssmPath install $ServiceName $hostExe | Out-Null +& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null +& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null +& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null +& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null +& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null +& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null +& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null +& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null + +& $NssmPath set $ServiceName AppEnvironmentExtra ` + "OTOPCUA_FOCAS_PIPE=$FocasPipeName" ` + "OTOPCUA_ALLOWED_SID=$allowedSid" ` + "OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" ` + "OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null + +& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null + +Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)." +Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend" +Write-Host "Start the service with: Start-Service $ServiceName" +Write-Host "" +Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH" +Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md." diff --git a/scripts/install/Uninstall-FocasHost.ps1 b/scripts/install/Uninstall-FocasHost.ps1 new file mode 100644 index 0000000..1605b40 --- /dev/null +++ b/scripts/install/Uninstall-FocasHost.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Removes the OtOpcUaFocasHost Windows service. + +.DESCRIPTION + Companion to Install-FocasHost.ps1. Stops + unregisters the service via NSSM. + Idempotent — succeeds silently if the service doesn't exist. + +.EXAMPLE + .\Uninstall-FocasHost.ps1 +#> +[CmdletBinding()] +param( + [string]$ServiceName = 'OtOpcUaFocasHost', + [string]$NssmPath = 'C:\Program Files\nssm\nssm.exe' +) + +$ErrorActionPreference = 'Stop' + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if (-not $svc) { Write-Host "Service '$ServiceName' not present — nothing to do."; return } + +if (-not (Test-Path $NssmPath)) { throw "nssm.exe not found at '$NssmPath'." } + +& $NssmPath stop $ServiceName confirm | Out-Null +& $NssmPath remove $ServiceName confirm | Out-Null +Write-Host "Removed '$ServiceName'." diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs new file mode 100644 index 0000000..90e7e9b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability; + +/// +/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a +/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see +/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader +/// (the supervisor) — the file format is identical to the Galaxy Tier-C +/// PostMortemMmf so a single reader tool can work both. +/// +/// +/// File layout: +/// +/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)] +/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]] +/// +/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF. +/// +public sealed class PostMortemMmf : IDisposable +{ + private const int Magic = 0x4F465043; // 'OFPC' + private const int Version = 1; + private const int HeaderBytes = 16; + public const int EntryBytes = 256; + private const int MessageOffset = 16; + private const int MessageCapacity = EntryBytes - MessageOffset; + + public int Capacity { get; } + public string Path { get; } + + private readonly MemoryMappedFile _mmf; + private readonly MemoryMappedViewAccessor _accessor; + private readonly object _writeGate = new(); + + public PostMortemMmf(string path, int capacity = 1000) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + Capacity = capacity; + Path = path; + + var fileBytes = HeaderBytes + capacity * EntryBytes; + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + + var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); + fs.SetLength(fileBytes); + _mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes, + MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false); + _accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite); + + if (_accessor.ReadInt32(0) != Magic) + { + _accessor.Write(0, Magic); + _accessor.Write(4, Version); + _accessor.Write(8, capacity); + _accessor.Write(12, 0); + } + } + + public void Write(long opKind, string message) + { + lock (_writeGate) + { + var idx = _accessor.ReadInt32(12); + var offset = HeaderBytes + idx * EntryBytes; + + _accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _accessor.Write(offset + 8, opKind); + + var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty); + var copy = Math.Min(msgBytes.Length, MessageCapacity - 1); + _accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy); + _accessor.Write(offset + MessageOffset + copy, (byte)0); + + var next = (idx + 1) % Capacity; + _accessor.Write(12, next); + } + } + + public PostMortemEntry[] ReadAll() + { + var magic = _accessor.ReadInt32(0); + if (magic != Magic) return new PostMortemEntry[0]; + + var capacity = _accessor.ReadInt32(8); + var writeIndex = _accessor.ReadInt32(12); + + var entries = new PostMortemEntry[capacity]; + var count = 0; + for (var i = 0; i < capacity; i++) + { + var slot = (writeIndex + i) % capacity; + var offset = HeaderBytes + slot * EntryBytes; + + var ts = _accessor.ReadInt64(offset + 0); + if (ts == 0) continue; + + var op = _accessor.ReadInt64(offset + 8); + var msgBuf = new byte[MessageCapacity]; + _accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity); + var nulTerm = Array.IndexOf(msgBuf, 0); + var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm); + + entries[count++] = new PostMortemEntry(ts, op, msg); + } + + Array.Resize(ref entries, count); + return entries; + } + + public void Dispose() + { + _accessor.Dispose(); + _mmf.Dispose(); + } +} + +public readonly struct PostMortemEntry +{ + public long UtcUnixMs { get; } + public long OpKind { get; } + public string Message { get; } + + public PostMortemEntry(long utcUnixMs, long opKind, string message) + { + UtcUnixMs = utcUnixMs; + OpKind = opKind; + Message = message; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs new file mode 100644 index 0000000..f5236dc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs @@ -0,0 +1,57 @@ +using System.IO.MemoryMappedFiles; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor; + +/// +/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor +/// opens the file (which persists beyond the process lifetime) and enumerates the last +/// few thousand IPC operations that were in flight. Format matches +/// Driver.FOCAS.Host.Stability.PostMortemMmf — magic 'OFPC' / 256-byte entries. +/// +public sealed class PostMortemReader +{ + private const int Magic = 0x4F465043; // 'OFPC' + private const int HeaderBytes = 16; + private const int EntryBytes = 256; + private const int MessageOffset = 16; + private const int MessageCapacity = EntryBytes - MessageOffset; + + public string Path { get; } + + public PostMortemReader(string path) => Path = path; + + public PostMortemEntry[] ReadAll() + { + if (!File.Exists(Path)) return []; + + using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + + if (accessor.ReadInt32(0) != Magic) return []; + + var capacity = accessor.ReadInt32(8); + var writeIndex = accessor.ReadInt32(12); + var entries = new PostMortemEntry[capacity]; + var count = 0; + + for (var i = 0; i < capacity; i++) + { + var slot = (writeIndex + i) % capacity; + var offset = HeaderBytes + slot * EntryBytes; + var ts = accessor.ReadInt64(offset + 0); + if (ts == 0) continue; + var op = accessor.ReadInt64(offset + 8); + var msgBuf = new byte[MessageCapacity]; + accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity); + var nulTerm = Array.IndexOf(msgBuf, 0); + var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm); + entries[count++] = new PostMortemEntry(ts, op, msg); + } + + Array.Resize(ref entries, count); + return entries; + } +} + +public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs new file mode 100644 index 0000000..f31145c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor; + +/// +/// Production . Spawns OtOpcUa.Driver.FOCAS.Host.exe +/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for +/// the pipe to come up, then connects a and wraps it in an +/// . On best-effort kills the +/// process and closes the IPC stream. +/// +public sealed class ProcessHostLauncher : IHostProcessLauncher +{ + private readonly ProcessHostLauncherOptions _options; + private Process? _process; + private FocasIpcClient? _ipc; + + public ProcessHostLauncher(ProcessHostLauncherOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public bool IsProcessAlive => _process is { HasExited: false }; + + public async Task LaunchAsync(CancellationToken ct) + { + await TerminateAsync(ct).ConfigureAwait(false); + + var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N"); + + var psi = new ProcessStartInfo + { + FileName = _options.HostExePath, + Arguments = _options.Arguments ?? string.Empty, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName; + psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid; + psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret; + psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend; + + _process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}"); + + // Poll for pipe readiness up to the configured connect timeout. + var deadline = DateTime.UtcNow + _options.ConnectTimeout; + while (true) + { + ct.ThrowIfCancellationRequested(); + if (_process.HasExited) + throw new InvalidOperationException( + $"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode})."); + + try + { + _ipc = await FocasIpcClient.ConnectAsync( + _options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + break; + } + catch (TimeoutException) + { + if (DateTime.UtcNow >= deadline) + throw new TimeoutException( + $"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}."); + await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false); + } + } + + return new IpcFocasClient(_ipc, _options.Series); + } + + public async Task TerminateAsync(CancellationToken ct) + { + if (_ipc is not null) + { + try { await _ipc.DisposeAsync().ConfigureAwait(false); } + catch { /* best effort */ } + _ipc = null; + } + + if (_process is not null) + { + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(ct).ConfigureAwait(false); + } + } + catch { /* best effort */ } + finally + { + _process.Dispose(); + _process = null; + } + } + } +} + +public sealed record ProcessHostLauncherOptions( + string HostExePath, + string PipeName, + string AllowedSid) +{ + public string? SharedSecret { get; init; } + public string? Arguments { get; init; } + public string Backend { get; init; } = "fwlib32"; + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15); + public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs new file mode 100644 index 0000000..45908b2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests +{ + [Trait("Category", "Unit")] + public sealed class PostMortemMmfTests : IDisposable + { + private readonly string _tempPath; + + public PostMortemMmfTests() + { + _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin"); + } + + public void Dispose() + { + if (File.Exists(_tempPath)) File.Delete(_tempPath); + } + + [Fact] + public void Write_and_read_preserve_order_and_content() + { + using (var mmf = new PostMortemMmf(_tempPath, capacity: 10)) + { + mmf.Write(opKind: 1, "read R100"); + mmf.Write(opKind: 2, "write MACRO:500 = 3.14"); + mmf.Write(opKind: 3, "probe ok"); + } + + // Reopen (simulating a reader after the writer crashed). + using var reader = new PostMortemMmf(_tempPath, capacity: 10); + var entries = reader.ReadAll(); + entries.Length.ShouldBe(3); + entries[0].OpKind.ShouldBe(1L); + entries[0].Message.ShouldBe("read R100"); + entries[1].OpKind.ShouldBe(2L); + entries[2].Message.ShouldBe("probe ok"); + } + + [Fact] + public void Ring_buffer_wraps_at_capacity() + { + using var mmf = new PostMortemMmf(_tempPath, capacity: 3); + for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}"); + + var entries = mmf.ReadAll(); + entries.Length.ShouldBe(3); + // Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order). + entries[0].Message.ShouldBe("op-7"); + entries[1].Message.ShouldBe("op-8"); + entries[2].Message.ShouldBe("op-9"); + } + + [Fact] + public void Truncated_message_is_null_terminated_and_does_not_overflow() + { + using var mmf = new PostMortemMmf(_tempPath, capacity: 4); + var big = new string('x', 500); // longer than the 240-byte message capacity + mmf.Write(42, big); + + var entries = mmf.ReadAll(); + entries.Length.ShouldBe(1); + entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240); + entries[0].OpKind.ShouldBe(42L); + } + + [Fact] + public void Reopening_with_existing_data_preserves_entries() + { + using (var first = new PostMortemMmf(_tempPath, capacity: 5)) + { + first.Write(1, "first-run-1"); + first.Write(2, "first-run-2"); + } + + using var second = new PostMortemMmf(_tempPath, capacity: 5); + var entries = second.ReadAll(); + entries.Length.ShouldBe(2); + entries[0].Message.ShouldBe("first-run-1"); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/PostMortemReaderCompatibilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/PostMortemReaderCompatibilityTests.cs new file mode 100644 index 0000000..622ef96 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/PostMortemReaderCompatibilityTests.cs @@ -0,0 +1,84 @@ +using System.IO.MemoryMappedFiles; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// The Proxy-side must read the Host's MMF format +/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics +/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two +/// codebases in lockstep on the wire format without needing to reference the net48 +/// Host assembly from the net10 test project. +/// +[Trait("Category", "Unit")] +public sealed class PostMortemReaderCompatibilityTests : IDisposable +{ + private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin"); + + public void Dispose() + { + if (File.Exists(_tempPath)) File.Delete(_tempPath); + } + + [Fact] + public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order() + { + const int magic = 0x4F465043; + const int capacity = 5; + const int headerBytes = 16; + const int entryBytes = 256; + const int messageOffset = 16; + var fileBytes = headerBytes + capacity * entryBytes; + + using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read)) + { + fs.SetLength(fileBytes); + using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes, + MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false); + using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite); + acc.Write(0, magic); + acc.Write(4, 1); + acc.Write(8, capacity); + acc.Write(12, 2); // writeIndex — next write would land at slot 2 + + void WriteEntry(int slot, long ts, long op, string msg) + { + var offset = headerBytes + slot * entryBytes; + acc.Write(offset + 0, ts); + acc.Write(offset + 8, op); + var bytes = Encoding.UTF8.GetBytes(msg); + acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length); + acc.Write(offset + messageOffset + bytes.Length, (byte)0); + } + + WriteEntry(0, 100, 1, "op-a"); + WriteEntry(1, 200, 2, "op-b"); + // Slots 2,3 unwritten (ts=0) — reader must skip. + WriteEntry(4, 50, 9, "old-wrapped"); + } + + var entries = new PostMortemReader(_tempPath).ReadAll(); + entries.Length.ShouldBe(3); + // writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1. + // Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b". + entries[0].Message.ShouldBe("old-wrapped"); + entries[1].Message.ShouldBe("op-a"); + entries[2].Message.ShouldBe("op-b"); + } + + [Fact] + public void Reader_returns_empty_when_file_missing() + { + new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty(); + } + + [Fact] + public void Reader_returns_empty_when_magic_mismatches() + { + File.WriteAllBytes(_tempPath, new byte[1024]); + new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty(); + } +}