fix(communication): release the _activeStreams slot when relay ActorOf throws (review 2026-10-21 03/M-2)

This commit is contained in:
Joseph Doherty
2026-10-22 06:13:43 -04:00
parent dd60e236bd
commit 15496575bc
2 changed files with 44 additions and 3 deletions
@@ -413,10 +413,28 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
correlationId, streamKind, n, channelCapacity);
});
// ActorOf can throw when the actor system is terminating through a path that never
// set _shuttingDown (the downed-node watchdog). The _activeStreams slot registered
// above would otherwise leak against GrpcMaxConcurrentStreams forever (review
// 2026-10-21 03/M-2), so release it — and the channel — before rethrowing.
var actorSeq = Interlocked.Increment(ref _actorCounter);
var relayActor = _actorSystem!.ActorOf(
Props.Create(typeof(Actors.StreamRelayActor), correlationId, channel.Writer),
$"stream-relay-{correlationId}-{actorSeq}");
IActorRef relayActor;
try
{
relayActor = _actorSystem!.ActorOf(
Props.Create(typeof(Actors.StreamRelayActor), correlationId, channel.Writer),
$"stream-relay-{correlationId}-{actorSeq}");
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not create the relay actor for {Description} (correlation {CorrelationId}); releasing the stream slot.",
description, correlationId);
channel.Writer.TryComplete();
_activeStreams.TryRemove(
new KeyValuePair<string, StreamEntry>(correlationId, entry));
throw;
}
// The previous code called _streamSubscriber.Subscribe
// OUTSIDE the try block that owns relay-actor cleanup. If Subscribe threw
@@ -478,6 +478,29 @@ public class SiteStreamGrpcServerTests : TestKit
subscriber.DidNotReceive().RemoveSubscriber(Arg.Any<IActorRef>());
}
/// <summary>
/// Review 2026-10-21 03/M-2: if <c>ActorOf</c> throws (the actor system terminating via
/// the downed-node watchdog, so <c>_shuttingDown</c> was never set), the
/// <c>_activeStreams</c> slot registered just before it must not leak.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActorOfThrows_DoesNotLeakActiveStreamSlot()
{
var terminated = ActorSystem.Create("m2-terminated");
await terminated.Terminate();
var server = CreateServer();
server.SetReady(terminated);
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
await Assert.ThrowsAnyAsync<Exception>(
() => server.SubscribeInstance(MakeRequest("corr-m2"), writer, CreateMockContext()));
Assert.Equal(0, server.ActiveStreamCount);
_subscriber.DidNotReceive().Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>());
}
/// <summary>Verifies that the active subscribers gauge rises to one on connect and returns to zero on cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]