fix(galaxy): seam hardening - advise unmark on throw, dup attributeRef surfaced, bounded subscribe-once read; delete dormant historian connection probe (#605)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:52:21 -04:00
parent 3baa332d6b
commit 7c4c7f9653
8 changed files with 132 additions and 90 deletions
@@ -257,4 +257,26 @@ public sealed class GalaxyDriverRawPathKeyingTests
return Task.CompletedTask;
}
}
/// <summary>Two RawPaths sharing one attributeRef: first wins the reverse map, the loser is recorded in the skipped ledger (06/S-13).</summary>
[Fact]
public void DuplicateAttributeRef_RecordsLosingRawPath_InSkippedLedger()
{
var opts = OptsWithRawTags() with
{
RawTags =
[
new RawTagEntry(RawPath, $"{{\"attributeRef\":\"{AttributeRef}\"}}", false),
new RawTagEntry("area/line/tank1/level-copy", $"{{\"attributeRef\":\"{AttributeRef}\"}}", false),
],
};
using var driver = new GalaxyDriver("g", opts, hierarchySource: null);
var report = driver.GetSkippedTags();
report.ShouldNotBeNull();
report.AuthoredCount.ShouldBe(2);
report.SkippedCount.ShouldBe(1);
report.Sample[0].RawPath.ShouldBe("area/line/tank1/level-copy");
report.Sample[0].Reason.ShouldContain(RawPath);
}
}
@@ -158,4 +158,25 @@ public sealed class GalaxyDriverReadTests
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
}
/// <summary>A never-publishing tag with CancellationToken.None must complete with BadTimeout at the internal deadline (06/S-3).</summary>
[Fact]
public async Task ReadAsync_SubscribeOncePath_NeverPublishes_NoCallerToken_TimesOutWithBadTimeout()
{
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber)
{
ReadViaSubscribeDeadline = TimeSpan.FromMilliseconds(200),
};
var readTask = driver.ReadAsync(["Silent.Tag"], CancellationToken.None);
var finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(10)));
finished.ShouldBe(readTask);
var result = await readTask;
result.Count.ShouldBe(1);
result[0].StatusCode.ShouldBe(StatusCodeMap.BadTimeout);
subscriber.UnsubscribedHandles.ShouldContain(subscriber.Map["Silent.Tag"]);
}
}
@@ -62,6 +62,37 @@ public sealed class GatewayGalaxyDataWriterFailClosedTests
writer.CachedSupervisedHandleCount.ShouldBe(0); // advise failure forgot the handle → next write retries
}
/// <summary>A THROWN advise (transport fault, not a non-OK status) must also forget the handle, or every later
/// write on it would skip the advise forever (06/S-12). The next write re-advises and succeeds.</summary>
[Fact]
public async Task WriteOne_AdviseThrows_FailsClosed_ForgetsHandle_AndNextWriteReAdvises()
{
var throwNext = true;
var ops = new FakeMxWriteOps
{
InvokeResponder = _ => throwNext
? throw new IOException("transport reset")
: new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } },
};
var writer = NewWriter();
var first = await writer.WriteOneForTestAsync(
ops, new WriteRequest("Tag.T", true), SecurityClassification.FreeAccess, CancellationToken.None);
first.StatusCode.ShouldBe(StatusCodeMap.BadCommunicationError);
ops.RawWriteHandles.ShouldBeEmpty();
writer.CachedSupervisedHandleCount.ShouldBe(0);
throwNext = false;
var second = await writer.WriteOneForTestAsync(
ops, new WriteRequest("Tag.T", true), SecurityClassification.FreeAccess, CancellationToken.None);
second.StatusCode.ShouldBe(StatusCodeMap.Good);
ops.Invokes.Count(r => r.Command.Kind == MxCommandKind.AdviseSupervisory).ShouldBe(2);
ops.RawWriteHandles.ShouldHaveSingleItem();
writer.CachedSupervisedHandleCount.ShouldBe(1);
}
/// <summary>When advise succeeds, the raw Write proceeds and a clean reply returns Good (regression).</summary>
[Fact]
public async Task WriteOne_AdviseSucceeds_RawWriteProceeds()
@@ -22,20 +22,6 @@ public sealed class GatewayHealthSnapshotTests
Assert.Equal(h.TotalQueries, h.TotalSuccesses + h.TotalFailures); // invariant
}
[Fact]
public async Task Connection_state_reflects_GetConnectionStatus_flags()
{
var fake = new FakeHistorianGatewayClient
{
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 }, // Process|Event
};
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken); // internal probe used by health hosted-service
var h = ds.GetHealthSnapshot();
Assert.True(h.ProcessConnectionOpen);
Assert.True(h.EventConnectionOpen);
}
[Fact]
public async Task GetHealthSnapshot_does_no_io_and_starts_with_connections_closed()
{
@@ -49,31 +35,17 @@ public sealed class GatewayHealthSnapshotTests
}
[Fact]
public async Task Disconnected_status_leaves_both_flags_closed()
public async Task Connection_flags_are_derived_from_observed_reads()
{
var fake = new FakeHistorianGatewayClient
{
ConnectionStatus = new ConnectionStatus { ConnectedToServer = false, ConnectionKind = 0b11 },
};
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken);
var h = ds.GetHealthSnapshot();
Assert.False(h.ProcessConnectionOpen);
Assert.False(h.EventConnectionOpen);
}
[Fact]
public async Task Failed_status_query_degrades_flags_without_throwing()
{
var fake = new FakeHistorianGatewayClient
{
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 },
GetConnectionStatusThrows = new InvalidOperationException("gateway unreachable"),
};
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken); // must not throw
await ds.ReadRawAsync("T", default, default, 1, TestContext.Current.CancellationToken);
Assert.True(ds.GetHealthSnapshot().ProcessConnectionOpen);
fake.ThrowOnRead = new InvalidOperationException("boom");
await Assert.ThrowsAnyAsync<Exception>(() => ds.ReadRawAsync("T", default, default, 1, TestContext.Current.CancellationToken));
var h = ds.GetHealthSnapshot();
Assert.False(h.ProcessConnectionOpen);
Assert.False(h.EventConnectionOpen);
Assert.Equal(0, fake.GetConnectionStatusCallCount); // never probes the gateway
}
}