FOCAS — retire Tier-C split, inline managed wire client, make read-only
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,52 @@ internal class FakeFocasClient : IFocasClient
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
|
||||
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
|
||||
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
|
||||
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
|
||||
|
||||
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
|
||||
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
|
||||
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
|
||||
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
|
||||
{
|
||||
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
|
||||
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
return Task.FromResult(snap);
|
||||
}
|
||||
|
||||
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
|
||||
{
|
||||
if (!Timers.TryGetValue(kind, out var t))
|
||||
t = new FocasTimer(kind, 0, 0);
|
||||
return Task.FromResult(t);
|
||||
}
|
||||
|
||||
public List<FocasServoLoad> ServoLoads { get; } = [];
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #220 — covers the DriverConfig JSON contract that
|
||||
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||
/// or CNC required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_adds_FOCAS_entry_to_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive_via_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("focas").ShouldNotBeNull();
|
||||
registry.TryGet("Focas").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "ipc",
|
||||
"PipeName": "OtOpcUaFocasHost",
|
||||
"SharedSecret": "secret-for-test",
|
||||
"ConnectTimeoutMs": 5000,
|
||||
"Series": "Thirty_i",
|
||||
"TimeoutMs": 3000,
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||
{
|
||||
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||
const string json = """
|
||||
{ "PipeName": "p", "SharedSecret": "s" }
|
||||
""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||
.Message.ShouldContain("PipeName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||
.Message.ShouldContain("SharedSecret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||
{
|
||||
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||
// natively on first use.
|
||||
const string json = """{ "Backend": "fwlib" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||
{
|
||||
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||
const string json = """{ "Backend": "unimplemented" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||
{
|
||||
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||
.Message.ShouldContain("gibberish");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_unknown_Series()
|
||||
{
|
||||
const string json = """
|
||||
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||
.Message.ShouldContain("NotARealSeries");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "fwlib",
|
||||
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||
}
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||
.Message.ShouldContain("DataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_twice_throws()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.Register(registry));
|
||||
}
|
||||
}
|
||||
@@ -219,11 +219,11 @@ public sealed class FocasScaffoldingTests
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Default_factory_throws_on_Create_with_deployment_pointer()
|
||||
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("Fwlib32.dll");
|
||||
ex.Message.ShouldContain("licensed");
|
||||
ex.Message.ShouldContain("wire");
|
||||
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
|
||||
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
|
||||
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FwlibNativeHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("G", 0)]
|
||||
[InlineData("F", 1)]
|
||||
[InlineData("Y", 2)]
|
||||
[InlineData("X", 3)]
|
||||
[InlineData("A", 4)]
|
||||
[InlineData("R", 5)]
|
||||
[InlineData("T", 6)]
|
||||
[InlineData("K", 7)]
|
||||
[InlineData("C", 8)]
|
||||
[InlineData("D", 9)]
|
||||
[InlineData("E", 10)]
|
||||
[InlineData("g", 0)] // case-insensitive
|
||||
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z")]
|
||||
[InlineData("")]
|
||||
[InlineData("XX")]
|
||||
public void PmcAddrType_rejects_unknown_letters(string letter)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasDataType.Bit, 0)] // byte
|
||||
[InlineData(FocasDataType.Byte, 0)]
|
||||
[InlineData(FocasDataType.Int16, 1)] // word
|
||||
[InlineData(FocasDataType.Int32, 2)] // long
|
||||
[InlineData(FocasDataType.Float32, 4)]
|
||||
[InlineData(FocasDataType.Float64, 5)]
|
||||
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
|
||||
{
|
||||
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
|
||||
((sbyte)buf[0]).ShouldBe((sbyte)-5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int16_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x34);
|
||||
buf[1].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x78);
|
||||
buf[1].ShouldBe((byte)0x56);
|
||||
buf[2].ShouldBe((byte)0x34);
|
||||
buf[3].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
|
||||
{
|
||||
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
|
||||
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
|
||||
// no-bit-index case (treated as a whole-byte boolean).
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)1);
|
||||
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeParamValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[32];
|
||||
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
|
||||
buf[0].ShouldBe((byte)0x0D);
|
||||
buf[1].ShouldBe((byte)0x0C);
|
||||
buf[2].ShouldBe((byte)0x0B);
|
||||
buf[3].ShouldBe((byte)0x0A);
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end IPC round-trips over an in-memory loopback: <c>IpcFocasClient</c> talks
|
||||
/// to a test fake that plays the Host's role by reading frames, dispatching on kind,
|
||||
/// and responding with canned DTOs. Validates that every <see cref="IFocasClient"/>
|
||||
/// method translates to the right wire frame + decodes the response correctly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IpcFocasClientTests
|
||||
{
|
||||
private const string Secret = "test-secret";
|
||||
|
||||
private static async Task ServerLoopAsync(Stream serverSide, Func<FocasMessageKind, byte[], FrameWriter, Task> dispatch, CancellationToken ct)
|
||||
{
|
||||
using var reader = new FrameReader(serverSide, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverSide, leaveOpen: true);
|
||||
|
||||
// Hello handshake.
|
||||
var first = await reader.ReadFrameAsync(ct);
|
||||
if (first is null) return;
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
var accepted = hello.SharedSecret == Secret;
|
||||
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||
new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct);
|
||||
if (!accepted) return;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(ct);
|
||||
if (frame is null) return;
|
||||
await dispatch(frame.Value.Kind, frame.Value.Body, writer);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_sends_OpenSessionRequest_and_caches_session_id()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
OpenSessionRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||
{
|
||||
received = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token);
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i);
|
||||
await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token);
|
||||
|
||||
client.IsConnected.ShouldBeTrue();
|
||||
received.ShouldNotBeNull();
|
||||
received!.HostAddress.ShouldBe("192.168.1.50:8193");
|
||||
received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_throws_when_host_rejects()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token);
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_sends_ReadRequest_and_decodes_response()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
ReadRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.ReadRequest:
|
||||
received = MessagePackSerializer.Deserialize<ReadRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.ReadResponse,
|
||||
new ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = 0,
|
||||
ValueBytes = MessagePackSerializer.Serialize((int)12345),
|
||||
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||
}, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null);
|
||||
var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
value.ShouldBe(12345);
|
||||
received!.Address.Number.ShouldBe(1815);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_sends_WriteRequest_and_returns_status()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.WriteRequest:
|
||||
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
|
||||
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14);
|
||||
await writer.WriteAsync(FocasMessageKind.WriteResponse,
|
||||
new WriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null),
|
||||
FocasDataType.Float64, 3.14, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_pmc_bit_sends_first_class_RMW_frame()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
PmcBitWriteRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.PmcBitWriteRequest:
|
||||
received = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse,
|
||||
new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5);
|
||||
var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
received.ShouldNotBeNull();
|
||||
received!.BitIndex.ShouldBe(5);
|
||||
received.Value.ShouldBeTrue();
|
||||
received.Address.PmcLetter.ShouldBe("R");
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_round_trips_health_from_host()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.ProbeRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.ProbeResponse,
|
||||
new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
(await client.ProbeAsync(cts.Token)).ShouldBeTrue();
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_response_from_host_surfaces_as_FocasIpcException()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token);
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
var ex = await Should.ThrowAsync<FocasIpcException>(async () =>
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||
ex.Code.ShouldBe("backend-exception");
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.IO.Pipelines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bidirectional in-memory stream pair for IPC tests. Two <c>System.IO.Pipelines.Pipe</c>
|
||||
/// instances — one per direction — exposed as <see cref="System.IO.Stream"/> endpoints
|
||||
/// via <c>PipeReader.AsStream</c> / <c>PipeWriter.AsStream</c>. Lets the test set up a
|
||||
/// <c>FocasIpcClient</c> on one end and a minimal fake server loop on the other without
|
||||
/// standing up a real named pipe.
|
||||
/// </summary>
|
||||
internal sealed class IpcLoopback : IAsyncDisposable
|
||||
{
|
||||
public Stream ClientSide { get; }
|
||||
public Stream ServerSide { get; }
|
||||
|
||||
public IpcLoopback()
|
||||
{
|
||||
var clientToServer = new Pipe();
|
||||
var serverToClient = new Pipe();
|
||||
|
||||
ClientSide = new DuplexPipeStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream());
|
||||
ServerSide = new DuplexPipeStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ClientSide.DisposeAsync();
|
||||
await ServerSide.DisposeAsync();
|
||||
}
|
||||
|
||||
private sealed class DuplexPipeStream(Stream read, Stream write) : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanWrite => true;
|
||||
public override bool CanSeek => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => read.Read(buffer, offset, count);
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||
read.ReadAsync(buffer, offset, count, ct);
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
|
||||
read.ReadAsync(buffer, ct);
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => write.Write(buffer, offset, count);
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||
write.WriteAsync(buffer, offset, count, ct);
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
|
||||
write.WriteAsync(buffer, ct);
|
||||
|
||||
public override void Flush() => write.Flush();
|
||||
public override Task FlushAsync(CancellationToken ct) => write.FlushAsync(ct);
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
read.Dispose();
|
||||
write.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The Proxy-side <see cref="PostMortemReader"/> 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_sequence_is_5s_15s_60s_then_clamped()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordStableRun_resets_the_ladder_to_the_start()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next(); b.Next();
|
||||
b.AttemptIndex.ShouldBe(2);
|
||||
b.RecordStableRun();
|
||||
b.AttemptIndex.ShouldBe(0);
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CircuitBreakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allows_crashes_below_threshold()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
b.TryRecordCrash(now, out _).ShouldBeTrue();
|
||||
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
|
||||
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
|
||||
b.StickyAlertActive.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opens_when_exceeding_threshold_in_window()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
b.TryRecordCrash(now, out _);
|
||||
b.TryRecordCrash(now.AddSeconds(1), out _);
|
||||
b.TryRecordCrash(now.AddSeconds(2), out _);
|
||||
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
|
||||
cooldown.ShouldBe(TimeSpan.FromHours(1));
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escalates_cooldown_after_second_open()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
// First burst — 4 crashes opens breaker with 1h cooldown.
|
||||
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
|
||||
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
|
||||
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
|
||||
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
|
||||
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
|
||||
b.TryRecordCrash(t1, out _);
|
||||
b.TryRecordCrash(t1.AddSeconds(1), out _);
|
||||
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
|
||||
cooldown.ShouldBe(TimeSpan.FromHours(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualReset_clears_everything()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
b.ManualReset();
|
||||
b.StickyAlertActive.ShouldBeFalse();
|
||||
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeartbeatMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Three_consecutive_misses_declares_dead()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ack_resets_the_miss_counter()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss(); m.RecordMiss();
|
||||
m.ConsecutiveMisses.ShouldBe(2);
|
||||
m.RecordAck(DateTime.UtcNow);
|
||||
m.ConsecutiveMisses.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasHostSupervisorTests
|
||||
{
|
||||
private sealed class FakeLauncher : IHostProcessLauncher
|
||||
{
|
||||
public int LaunchAttempts { get; private set; }
|
||||
public int Terminations { get; private set; }
|
||||
public Queue<Func<IFocasClient>> Plan { get; } = new();
|
||||
public bool IsProcessAlive { get; set; }
|
||||
|
||||
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||
{
|
||||
LaunchAttempts++;
|
||||
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
|
||||
var next = Plan.Dequeue()();
|
||||
IsProcessAlive = true;
|
||||
return Task.FromResult(next);
|
||||
}
|
||||
|
||||
public Task TerminateAsync(CancellationToken ct)
|
||||
{
|
||||
Terminations++;
|
||||
IsProcessAlive = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubFocasClient : IFocasClient
|
||||
{
|
||||
public bool IsConnected => true;
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
|
||||
Task.FromResult<(object?, uint)>((0, 0));
|
||||
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrLaunch_returns_client_on_first_success()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
client.ShouldNotBeNull();
|
||||
launcher.LaunchAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
|
||||
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
|
||||
var supervisor = new FocasHostSupervisor(launcher, backoff);
|
||||
|
||||
var unavailableMessages = new List<string>();
|
||||
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
|
||||
|
||||
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
client.ShouldNotBeNull();
|
||||
launcher.LaunchAttempts.ShouldBe(2);
|
||||
unavailableMessages.Count.ShouldBe(1);
|
||||
unavailableMessages[0].ShouldContain("launch-failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
for (var i = 0; i < 10; i++)
|
||||
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
|
||||
|
||||
var supervisor = new FocasHostSupervisor(
|
||||
launcher,
|
||||
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("circuit breaker");
|
||||
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
|
||||
var messages = new List<string>();
|
||||
supervisor.OnUnavailable += m => messages.Add(m);
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
|
||||
|
||||
launcher.Terminations.ShouldBe(1);
|
||||
messages.ShouldContain("heartbeat-loss");
|
||||
supervisor.ObservedCrashes.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAndReset_clears_sticky_alert()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
for (var i = 0; i < 10; i++)
|
||||
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
|
||||
var supervisor = new FocasHostSupervisor(
|
||||
launcher,
|
||||
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
|
||||
|
||||
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
|
||||
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||
|
||||
supervisor.AcknowledgeAndReset();
|
||||
supervisor.StickyAlertActive.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_terminates_host_process()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
supervisor.Dispose();
|
||||
launcher.Terminations.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user