fix(communication): resolve Communication-002/003 — gRPC reconnect stream cleanup and subscription map safety
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user