feat(hosts): driver diagnostics channel on /hosts, S7 negotiated PDU size first (#640)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:57:38 -04:00
parent a8b07ae12d
commit c2bdb7d5de
23 changed files with 469 additions and 20 deletions
@@ -272,6 +272,24 @@ public sealed class HostsDriverViewTests
row.SkippedTags!.SkippedCount.ShouldBe(20);
}
/// <summary>#640: diagnostics ride the snapshot onto the row, ordered by key; none renders nothing.</summary>
[Fact]
public void Diagnostics_reach_the_row_ordered_and_absent_diagnostics_yield_no_items()
{
var with = Snap("MAIN", "s7-a") with
{
Diagnostics = new Dictionary<string, string> { ["Z"] = "2", ["S7.NegotiatedPduSize"] = "480" },
};
var without = Snap("MAIN", "s7-b");
var rows = HostsDriverView.Build([with, without], nodes: null, instances: null).Single().Drivers;
var a = rows.Single(r => r.DriverInstanceId == "s7-a");
a.DiagnosticItems.Select(k => k.Key).ShouldBe(["S7.NegotiatedPduSize", "Z"]);
a.DiagnosticItems[0].Value.ShouldBe("480");
rows.Single(r => r.DriverInstanceId == "s7-b").DiagnosticItems.ShouldBeEmpty();
}
/// <summary>
/// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
/// list, and an operator who fixes the twenty named tags believes the job is done.
@@ -276,6 +276,43 @@ public sealed class TelemetryProtoMapCentralTests
TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.ShouldBeNull();
}
/// <summary>
/// #640: the diagnostics bag round-trips with the null vs empty distinction intact, and a pre-#640
/// node (field never set) decodes to null.
/// </summary>
[Fact]
public void ToHealth_diagnostics_round_trip_with_the_null_vs_empty_distinction_intact()
{
var populated = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
HasDiagnostics = true,
Diagnostics = { ["S7.NegotiatedPduSize"] = "480", ["Other"] = "x" },
};
var mapped = TelemetryProtoMapCentral.ToHealth(populated).Diagnostics;
mapped.ShouldNotBeNull();
mapped!.Count.ShouldBe(2);
mapped["S7.NegotiatedPduSize"].ShouldBe("480");
var empty = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
HasDiagnostics = true,
};
var mappedEmpty = TelemetryProtoMapCentral.ToHealth(empty).Diagnostics;
mappedEmpty.ShouldNotBeNull();
mappedEmpty!.ShouldBeEmpty();
var absent = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
};
TelemetryProtoMapCentral.ToHealth(absent).Diagnostics.ShouldBeNull();
}
/// <summary>
/// An unparseable host state degrades to <see cref="HostState.Unknown"/> rather than throwing. A node
/// running a newer build that added an enum member must not be able to kill central's telemetry
@@ -248,6 +248,33 @@ public sealed class TelemetryStreamGrpcServiceTests
.DriverHealth.HasHostStatuses.ShouldBeFalse();
}
/// <summary>
/// Node side of the #640 diagnostics carry: presence flag + entries written; a capability-less driver
/// (null) leaves the flag clear, and an empty bag still sets it.
/// </summary>
[Fact]
public void ToProto_health_writes_the_diagnostics_presence_flag_and_entries()
{
var withDiag = new DriverHealthChanged(
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = "480" });
var evt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(withDiag), "c");
evt.DriverHealth.HasDiagnostics.ShouldBeTrue();
evt.DriverHealth.Diagnostics["S7.NegotiatedPduSize"].ShouldBe("480");
var empty = new DriverHealthChanged(
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string>());
var emptyEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(empty), "c");
emptyEvt.DriverHealth.HasDiagnostics.ShouldBeTrue();
emptyEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
var none = new DriverHealthChanged("cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow);
var noneEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(none), "c");
noneEvt.DriverHealth.HasDiagnostics.ShouldBeFalse();
noneEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
}
[Fact]
public async Task Client_disconnect_mid_stream_ends_cleanly_without_leaking_a_slot()
{
@@ -0,0 +1,187 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers the <see cref="IDriverDiagnostics"/> consumer (Gitea #640). A diagnostic fact (S7's negotiated
/// PDU size) moves no other fingerprint component, so unless its digest is in
/// <c>PublishHealthSnapshot</c>'s fingerprint the dedup swallows the very publish that carries it.
/// <para>The stub's health is deliberately STABLE: the shared <c>StubDriver.GetHealth()</c> returns
/// <c>UtcNow</c>, so the dedup never engages and a test on it passes vacuously.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDiagnosticsTests : RuntimeActorTestBase
{
/// <summary>Base case: the bag reaches the publisher.</summary>
[Fact]
public void Diagnostics_reach_the_health_publisher()
{
var driver = new DiagStubDriver();
driver.Set(("S7.NegotiatedPduSize", "480"));
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() =>
{
var latest = publisher.Published.LastOrDefault();
latest.ShouldNotBeNull();
latest!["S7.NegotiatedPduSize"].ShouldBe("480");
}, TimeSpan.FromSeconds(3));
}
/// <summary>
/// The load-bearing case. Falsifiability: publish count must STRICTLY increase across the change;
/// drop <c>DiagnosticsDigest</c> from the fingerprint and this goes red.
/// </summary>
[Fact]
public void A_diagnostics_change_republishes_despite_otherwise_unchanged_health()
{
var driver = new DiagStubDriver();
driver.Set(("S7.NegotiatedPduSize", "240"));
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var before = publisher.Published.Count;
driver.Set(("S7.NegotiatedPduSize", "480"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3));
publisher.Published[^1]!["S7.NegotiatedPduSize"].ShouldBe("480");
}
/// <summary>Equal-but-distinct bags must be deduped: the digest compares by VALUE, not reference.</summary>
[Fact]
public void An_unchanged_diagnostics_bag_does_not_defeat_the_dedup()
{
var driver = new DiagStubDriver();
driver.Set(("S7.NegotiatedPduSize", "480"));
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var before = publisher.Published.Count;
driver.Set(("S7.NegotiatedPduSize", "480"));
ExpectNoMsg(TimeSpan.FromMilliseconds(600));
publisher.Published.Count.ShouldBe(before);
}
/// <summary>A driver without the capability publishes null, not an empty bag.</summary>
[Fact]
public void A_driver_without_the_capability_publishes_null()
{
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(new StubDriver(), publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
publisher.Published.ShouldAllBe(p => p == null);
}
/// <summary>A throwing implementation loses only the diagnostics, not the health publish.</summary>
[Fact]
public void A_throwing_diagnostics_provider_degrades_to_null_without_killing_the_health_publish()
{
var driver = new DiagStubDriver { Throw = true };
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
publisher.Published[^1].ShouldBeNull();
}
/// <summary>The bag is capped and truncated before it is published.</summary>
[Fact]
public void Oversized_bags_are_bounded()
{
var driver = new DiagStubDriver();
driver.Set([.. Enumerable.Range(0, 40).Select(i => ($"k{i:D2}", new string('x', 500)))]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() =>
{
var latest = publisher.Published.LastOrDefault();
latest.ShouldNotBeNull();
latest!.Count.ShouldBe(DriverDiagnostics.MaxEntries);
latest.Values.ShouldAllBe(v => v.Length == DriverDiagnostics.MaxValueLength);
}, TimeSpan.FromSeconds(3));
}
private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher)
{
var parent = CreateTestProbe();
parent.IgnoreMessages(_ => true);
return parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(50)));
}
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
{
private readonly List<IReadOnlyDictionary<string, string>?> _published = [];
public IReadOnlyList<IReadOnlyDictionary<string, string>?> Published
{
get { lock (_published) return _published.ToArray(); }
}
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
lock (_published) _published.Add(diagnostics);
}
}
/// <summary>Stable-health driver exposing <see cref="IDriverDiagnostics"/>.</summary>
private sealed class DiagStubDriver : IDriver, IDriverDiagnostics
{
private static readonly DateTime FixedLastRead = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
private volatile IReadOnlyDictionary<string, string> _bag = new Dictionary<string, string>();
public bool Throw { get; init; }
public string DriverInstanceId => "diag-stub-1";
public string DriverType => "Stub";
public void Set(params (string Key, string Value)[] items) =>
_bag = items.ToDictionary(i => i.Key, i => i.Value);
public IReadOnlyDictionary<string, string> GetDiagnostics() =>
Throw ? throw new InvalidOperationException("broken") : _bag;
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
}
}
@@ -211,7 +211,8 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
lock (_published) _published.Add(new HealthPublish(health, hostStatuses));
}
@@ -187,7 +187,8 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
lock (_published)
{
@@ -209,7 +209,8 @@ public sealed class DriverInstanceActorSkippedTagTests : RuntimeActorTestBase
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
lock (_published) _published.Add(new HealthPublish(health, skippedTags));
}
@@ -124,7 +124,8 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
=> Interlocked.Increment(ref _count);
}
}