fix(communication): resolve Communication-002/003 — gRPC reconnect stream cleanup and subscription map safety

This commit is contained in:
Joseph Doherty
2026-05-16 19:33:09 -04:00
parent 87f14c190a
commit 301e7fb854
5 changed files with 134 additions and 7 deletions

View File

@@ -159,6 +159,34 @@ public class DebugStreamBridgeActorTests : TestKit
Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
}
[Fact]
public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect()
{
// Communication-002 regression: a reconnect must unsubscribe the previous
// stream so the old node does not keep a zombie relay actor / subscription.
var ctx = CreateBridgeActor();
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
var snapshot = new DebugViewSnapshot(
InstanceName,
new List<AttributeValueChanged>(),
new List<AlarmStateChanged>(),
DateTimeOffset.UtcNow);
ctx.BridgeActor.Tell(snapshot);
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
// Simulate gRPC error → reconnect
ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));
// Should resubscribe...
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
// ...and must have unsubscribed the prior correlation ID so the old node's
// relay actor is released rather than left zombie.
Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
}
[Fact]
public void After_MaxRetries_Terminates()
{

View File

@@ -176,4 +176,49 @@ public class SiteStreamGrpcClientTests
Assert.True(cts1.IsCancellationRequested);
Assert.True(cts2.IsCancellationRequested);
}
// --- Communication-003 regression tests ---
[Fact]
public void RegisterSubscription_ReusedCorrelationId_CancelsAndDisposesPriorCts()
{
// Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race).
// Inserting the second must cancel + dispose the first so it does not leak.
var client = SiteStreamGrpcClient.CreateForTesting();
var first = new CancellationTokenSource();
var second = new CancellationTokenSource();
client.RegisterSubscription("corr-shared", first);
client.RegisterSubscription("corr-shared", second);
Assert.True(first.IsCancellationRequested);
// Disposed CTS throws ObjectDisposedException when its token is touched.
Assert.Throws<ObjectDisposedException>(() => _ = first.Token);
// The second (live) CTS must remain intact.
Assert.False(second.IsCancellationRequested);
}
[Fact]
public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement()
{
// First call's finally must NOT remove the second call's live entry.
var client = SiteStreamGrpcClient.CreateForTesting();
var first = new CancellationTokenSource();
var second = new CancellationTokenSource();
client.RegisterSubscription("corr-shared", first);
// A racing second SubscribeAsync replaces the entry.
client.RegisterSubscription("corr-shared", second);
// The first call's finally runs and tries to remove its (already-replaced) entry.
client.RemoveSubscription("corr-shared", first);
// The live (second) subscription must still be cancellable via Unsubscribe.
Assert.False(second.IsCancellationRequested);
client.Unsubscribe("corr-shared");
Assert.True(second.IsCancellationRequested);
}
}