6531ec1984
After the fleet splits into one Akka mesh per application Cluster, a ClusterClientReceptionist serves only its own mesh — so the single fleet-wide client's SendToAll reached only the mesh whose receptionist answered, leaving every other cluster silently on its old configuration. Central now holds one ClusterClient per ClusterId and fans SendToAll across all of them. LoadContactsFromDb selects ClusterId and groups the receptionist contacts per cluster (keeping the per-row TryParse guard and the enabled/non-maintenance filter); ContactsLoaded carries ContactsByCluster; HandleContactsLoaded diffs per cluster (rebuild changed, stop+drop vanished, warn-don't-create on empty); RebuildClient builds a per-cluster client under a clusterId-derived actor name. IMeshClusterClientFactory.Create gained a clusterId parameter so the per-cluster clients get distinct, diagnosable names. ApplyAck handling and the DPS branch are unchanged; the deploy path stays payload-free. Tests: focused unit coverage of the grouping + fan-out (one-client-per-cluster, fan-out SendToAll to every cluster client) via the recording factory double, plus the real two-mesh boundary test extended to central + two separate site meshes proving one dispatch reaches both and a client scoped to one cluster never crosses into another. Red-before-green verified: crippling the fan-out to a single client fails the reaches-both-meshes assertion. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
447 lines
21 KiB
C#
447 lines
21 KiB
C#
using System.Collections.Immutable;
|
|
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Akka.Cluster.Tools.Client;
|
|
using Akka.Configuration;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Communication;
|
|
using AkkaCluster = Akka.Cluster.Cluster;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Drives a real <c>ClusterClient</c> across two genuinely separate <see cref="ActorSystem"/>s —
|
|
/// the only test in the repo where the mesh boundary carries a message over the wire.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The sister project substitutes an in-process relay that unwraps
|
|
/// <see cref="ClusterClient.Send"/> and forwards. That tests actor <i>logic</i> and cannot
|
|
/// fail for any transport reason: not a missing receptionist extension, not a service that
|
|
/// was never registered, not a malformed contact path, not a serialization break. Those are
|
|
/// precisely the Phase 2 failure modes, so they are what this test exercises.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Two clusters, one ActorSystem name, joined ONLY by ClusterClient.</b> The shared name
|
|
/// is mandatory — Akka.Remote matches on the full address, so a contact path naming
|
|
/// <c>otopcua</c> cannot reach a system called anything else. Each system seeds itself and
|
|
/// neither lists the other, and
|
|
/// <see cref="The_two_systems_are_separate_clusters_joined_only_by_the_client"/> asserts the
|
|
/// separation outright: if they ever gossiped into one mesh, every delivery below could be
|
|
/// plain local messaging and the whole class would be testing nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Scope limit.</b> With exactly one node per side, "registered per node" and "registered
|
|
/// as a cluster singleton" are indistinguishable by construction, so this class does NOT
|
|
/// cover the per-node property whose structural assertions <c>MeshCommActorPathTests</c>
|
|
/// dropped. That property's discriminating coverage is live-gate step 8: stop the OLDEST
|
|
/// central and the survivor's comm actor must still receive a node ack, which a singleton
|
|
/// hosted on the stopped node cannot.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MeshClusterClientBoundaryTests : IAsyncLifetime
|
|
{
|
|
private const string SharedSystemName = "otopcua";
|
|
private static readonly TimeSpan Arrival = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// How long the falsifiability control waits before concluding nothing arrived. Deliberately
|
|
/// shorter than <see cref="Arrival"/> but far longer than a successful delivery takes in
|
|
/// practice, so a pass means "the boundary is shut", not "the clock ran out first".
|
|
/// </summary>
|
|
private static readonly TimeSpan Silence = TimeSpan.FromSeconds(10);
|
|
|
|
/// <summary>
|
|
/// One factory for every client this class creates, because the generation counter that keeps
|
|
/// client actor names unique is <b>per factory instance</b>. A second factory on the same
|
|
/// system restarts at generation 1 and collides with the first factory's client — which is
|
|
/// exactly how this test first failed. Production has one instance per
|
|
/// <c>CentralCommunicationActor</c>, so the real code path is safe.
|
|
/// </summary>
|
|
private readonly DefaultMeshClusterClientFactory _clientFactory = new();
|
|
|
|
private ActorSystem _central = null!;
|
|
private ActorSystem _node = null!;
|
|
private IActorRef _nodeComm = null!;
|
|
private IActorRef _centralClient = null!;
|
|
private TaskCompletionSource<object> _centralInbox = null!;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
_central = StartSelfSeededSystem(withReceptionist: true);
|
|
_node = StartSelfSeededSystem(withReceptionist: true);
|
|
await Task.WhenAll(AwaitSingleMemberUpAsync(_central), AwaitSingleMemberUpAsync(_node));
|
|
|
|
// Central: the ack landing pad, at the path the node's comm actor sends to.
|
|
_centralInbox = NewInbox();
|
|
var centralComm = _central.ActorOf(
|
|
CaptureActor.Props(_centralInbox), MeshPaths.CentralCommunicationName);
|
|
ClusterClientReceptionist.Get(_central).RegisterService(centralComm);
|
|
|
|
// Node: the REAL comm actor, dialling central through the REAL production client factory.
|
|
var nodeToCentral = _clientFactory.Create(_node, "central", ReceptionistOf(_central));
|
|
_nodeComm = _node.ActorOf(
|
|
NodeCommunicationActor.Props(nodeToCentral), MeshPaths.NodeCommunicationName);
|
|
ClusterClientReceptionist.Get(_node).RegisterService(_nodeComm);
|
|
|
|
_centralClient = _clientFactory.Create(_central, "site-a", ReceptionistOf(_node));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await ShutdownAsync(_central);
|
|
await ShutdownAsync(_node);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_two_systems_are_separate_clusters_joined_only_by_the_client()
|
|
{
|
|
// The load-bearing precondition of every other test here. Two systems sharing a name in one
|
|
// process are one gossip accident away from being one cluster, at which point ClusterClient
|
|
// is no longer the thing under test.
|
|
AkkaCluster.Get(_central).State.Members.Count.ShouldBe(1,
|
|
"central must be its own single-member cluster, or the boundary is not being crossed");
|
|
AkkaCluster.Get(_node).State.Members.Count.ShouldBe(1,
|
|
"the node must be its own single-member cluster, or the boundary is not being crossed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Central_send_to_all_reaches_the_node_comm_actor_and_lands_on_its_event_stream()
|
|
{
|
|
var inbox = NewInbox();
|
|
_node.EventStream.Subscribe(_node.ActorOf(CaptureActor.Props(inbox)), typeof(RestartDriver));
|
|
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
var delivered = await SendUntilDeliveredAsync(
|
|
_centralClient,
|
|
() => new ClusterClient.SendToAll(MeshPaths.NodeCommunication, command),
|
|
inbox,
|
|
Arrival);
|
|
|
|
delivered.ShouldNotBeNull(
|
|
"SendToAll must reach the node's registered comm actor and be re-emitted node-locally; "
|
|
+ "this is the whole central→node leg of the mesh transport");
|
|
delivered.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_node_ack_reaches_central_through_the_node_comm_actors_own_client()
|
|
{
|
|
// Told directly rather than routed from central, so a failure here can only be the outbound
|
|
// leg. The inbound leg has its own test above.
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("node-a"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
CorrelationId.NewId());
|
|
|
|
var delivered = await SendUntilDeliveredAsync(
|
|
_nodeComm, () => ack, _centralInbox, Arrival);
|
|
|
|
delivered.ShouldNotBeNull(
|
|
"the node's ApplyAck must reach central's comm actor, or every deployment times out at "
|
|
+ "the apply deadline naming nodes that applied successfully");
|
|
delivered.ShouldBeOfType<ApplyAck>().NodeId.Value.ShouldBe("node-a");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Falsifiability_control_a_node_without_the_receptionist_extension_receives_nothing()
|
|
{
|
|
// Without this, the two tests above cannot distinguish "the ClusterClient boundary works"
|
|
// from "something else in the process delivered the message". Same message, same client
|
|
// factory, same paths, same comm actor — and it must NOT arrive.
|
|
//
|
|
// HONEST SCOPE: this removes the receptionist extension AND, unavoidably, the RegisterService
|
|
// call — resolving ClusterClientReceptionist.Get to register would materialise the very
|
|
// extension whose absence is the control. So it falsifies "delivery happens without a
|
|
// receptionist boundary", not the extension line in isolation. Verified falsifiable: giving
|
|
// this node the extension and the registration turns the test red, which also proves the send
|
|
// below is live rather than silently misaddressed.
|
|
var deaf = StartSelfSeededSystem(withReceptionist: false);
|
|
try
|
|
{
|
|
await AwaitSingleMemberUpAsync(deaf);
|
|
|
|
var inbox = NewInbox();
|
|
deaf.EventStream.Subscribe(deaf.ActorOf(CaptureActor.Props(inbox)), typeof(RestartDriver));
|
|
|
|
// Deliberately NOT ClusterClientReceptionist.Get(deaf) — resolving the extension would
|
|
// materialise the very receptionist whose absence is the control.
|
|
var comm = deaf.ActorOf(NodeCommunicationActor.Props(null), MeshPaths.NodeCommunicationName);
|
|
comm.ShouldNotBeNull();
|
|
|
|
var client = _clientFactory.Create(_central, "deaf", ReceptionistOf(deaf));
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
|
|
var deadline = DateTime.UtcNow + Silence;
|
|
while (DateTime.UtcNow < deadline && !inbox.Task.IsCompleted)
|
|
{
|
|
client.Tell(new ClusterClient.SendToAll(MeshPaths.NodeCommunication, command));
|
|
await Task.Delay(500, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
inbox.Task.IsCompleted.ShouldBeFalse(
|
|
"delivery survived removing the receptionist extension, so the two positive tests "
|
|
+ "above are not measuring the ClusterClient boundary at all");
|
|
}
|
|
finally
|
|
{
|
|
await ShutdownAsync(deaf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh()
|
|
{
|
|
// THE Phase 6 assertion at the transport level, driving the REAL CentralCommunicationActor.
|
|
// Two genuinely separate site meshes (site-A = _node, site-B created here), each its own
|
|
// single-member cluster with its own receptionist. Central must build ONE ClusterClient per
|
|
// cluster and fan SendToAll across them, so a single MeshCommand reaches BOTH meshes. A single
|
|
// fleet-wide client would reach only the one mesh whose receptionist answered — the exact
|
|
// failure this test is red-before-green against.
|
|
var siteB = StartSelfSeededSystem(withReceptionist: true);
|
|
try
|
|
{
|
|
await AwaitSingleMemberUpAsync(siteB);
|
|
RegisterNodeComm(siteB);
|
|
|
|
var inboxA = NewInbox();
|
|
_node.EventStream.Subscribe(_node.ActorOf(CaptureActor.Props(inboxA)), typeof(RestartDriver));
|
|
var inboxB = NewInbox();
|
|
siteB.EventStream.Subscribe(siteB.ActorOf(CaptureActor.Props(inboxB)), typeof(RestartDriver));
|
|
|
|
var dbFactory = SeedTwoClusterDb(SiteAddress(_node), SiteAddress(siteB));
|
|
var actor = _central.ActorOf(CentralCommunicationActor.Props(
|
|
dbFactory, ClusterClientOptions(), new DefaultMeshClusterClientFactory(), () => null));
|
|
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
var deadline = DateTime.UtcNow + Arrival;
|
|
while (DateTime.UtcNow < deadline && !(inboxA.Task.IsCompleted && inboxB.Task.IsCompleted))
|
|
{
|
|
actor.Tell(new MeshCommand("deployments", command));
|
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
inboxA.Task.IsCompleted.ShouldBeTrue(
|
|
"site-A's mesh must receive the dispatch through its own per-cluster ClusterClient");
|
|
inboxB.Task.IsCompleted.ShouldBeTrue(
|
|
"site-B's mesh must receive the SAME dispatch through its own per-cluster ClusterClient; "
|
|
+ "a single fleet-wide client would have reached only one of the two meshes");
|
|
}
|
|
finally
|
|
{
|
|
await ShutdownAsync(siteB);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_client_built_for_one_cluster_does_not_deliver_into_another_clusters_mesh()
|
|
{
|
|
// The partition proof for the fan-out: a client dialling ONLY site-A's contacts must land in
|
|
// site-A's mesh and NEVER cross into site-B's. This is why the per-cluster fan-out is correct
|
|
// rather than accidentally N x broadcasting — each client is scoped to its own mesh.
|
|
var siteB = StartSelfSeededSystem(withReceptionist: true);
|
|
try
|
|
{
|
|
await AwaitSingleMemberUpAsync(siteB);
|
|
RegisterNodeComm(siteB);
|
|
|
|
var inboxA = NewInbox();
|
|
_node.EventStream.Subscribe(_node.ActorOf(CaptureActor.Props(inboxA)), typeof(RestartDriver));
|
|
var inboxB = NewInbox();
|
|
siteB.EventStream.Subscribe(siteB.ActorOf(CaptureActor.Props(inboxB)), typeof(RestartDriver));
|
|
|
|
var clientA = _clientFactory.Create(_central, "site-a", ReceptionistOf(_node));
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
|
|
// Keep sending for the full Silence window so any erroneous cross-delivery into site-B has
|
|
// every chance to arrive — a pass means the boundary is shut, not that the clock ran out.
|
|
var deadline = DateTime.UtcNow + Silence;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
clientA.Tell(new ClusterClient.SendToAll(MeshPaths.NodeCommunication, command));
|
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
inboxA.Task.IsCompleted.ShouldBeTrue("site-A's client must deliver into site-A's mesh");
|
|
inboxB.Task.IsCompleted.ShouldBeFalse(
|
|
"a client built for site-A's contacts must NOT deliver into site-B's mesh — the "
|
|
+ "per-cluster fan-out must stay partitioned, one client per mesh");
|
|
}
|
|
finally
|
|
{
|
|
await ShutdownAsync(siteB);
|
|
}
|
|
}
|
|
|
|
/// <summary>Registers a real node-comm actor with a system's receptionist so central can reach it.</summary>
|
|
/// <param name="system">The site system to register into.</param>
|
|
private static void RegisterNodeComm(ActorSystem system)
|
|
{
|
|
var comm = system.ActorOf(NodeCommunicationActor.Props(null), MeshPaths.NodeCommunicationName);
|
|
ClusterClientReceptionist.Get(system).RegisterService(comm);
|
|
}
|
|
|
|
private static (string Host, int Port) SiteAddress(ActorSystem system)
|
|
{
|
|
var addr = AkkaCluster.Get(system).SelfAddress;
|
|
return (addr.Host!, addr.Port!.Value);
|
|
}
|
|
|
|
private static MeshTransportOptions ClusterClientOptions() => new()
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints = ["akka.tcp://otopcua@127.0.0.1:4053"],
|
|
ContactRefreshSeconds = 1,
|
|
};
|
|
|
|
private static IDbContextFactory<OtOpcUaConfigDbContext> SeedTwoClusterDb(
|
|
(string Host, int Port) siteA, (string Host, int Port) siteB)
|
|
{
|
|
var factory = new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
|
|
using var db = factory.CreateDbContext();
|
|
db.ClusterNodes.Add(NodeRow("SITE-A", "node-a", siteA));
|
|
db.ClusterNodes.Add(NodeRow("SITE-B", "node-b", siteB));
|
|
db.SaveChanges();
|
|
return factory;
|
|
}
|
|
|
|
private static ClusterNode NodeRow(string clusterId, string nodeId, (string Host, int Port) addr) => new()
|
|
{
|
|
NodeId = nodeId,
|
|
ClusterId = clusterId,
|
|
Host = addr.Host,
|
|
AkkaPort = addr.Port,
|
|
ApplicationUri = $"urn:{nodeId}",
|
|
Enabled = true,
|
|
MaintenanceMode = false,
|
|
CreatedBy = "test",
|
|
};
|
|
|
|
private sealed class InMemoryConfigDbFactory(string dbName)
|
|
: IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
public OtOpcUaConfigDbContext CreateDbContext()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase(dbName)
|
|
.Options;
|
|
return new OtOpcUaConfigDbContext(opts);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a single-member cluster on a dynamic port, optionally without the receptionist.
|
|
/// </summary>
|
|
/// <param name="withReceptionist">
|
|
/// <see langword="false"/> drops the receptionist provider from <c>akka.extensions</c>,
|
|
/// leaving DistributedPubSub in place so only the one variable changes.
|
|
/// </param>
|
|
/// <returns>The started system.</returns>
|
|
private static ActorSystem StartSelfSeededSystem(bool withReceptionist)
|
|
{
|
|
var extensions = withReceptionist
|
|
? string.Empty
|
|
: "akka.extensions = [\"Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, "
|
|
+ "Akka.Cluster.Tools\"]\n";
|
|
|
|
// Port 0 + a post-start self-Join, rather than a seed-nodes list: the address is not known
|
|
// until the transport binds, and hard-coding ports makes the test collide with itself under
|
|
// parallel execution.
|
|
var config = ConfigurationFactory.ParseString(
|
|
"akka.remote.dot-netty.tcp.hostname = \"127.0.0.1\"\n"
|
|
+ "akka.remote.dot-netty.tcp.public-hostname = \"127.0.0.1\"\n"
|
|
+ "akka.remote.dot-netty.tcp.port = 0\n"
|
|
+ "akka.cluster.seed-nodes = []\n"
|
|
+ "akka.loglevel = \"WARNING\"\n"
|
|
+ extensions)
|
|
.WithFallback(ZB.MOM.WW.OtOpcUa.Cluster.HoconLoader.LoadBaseConfig());
|
|
|
|
var system = ActorSystem.Create(SharedSystemName, config);
|
|
var cluster = AkkaCluster.Get(system);
|
|
cluster.Join(cluster.SelfAddress);
|
|
return system;
|
|
}
|
|
|
|
private static async Task AwaitSingleMemberUpAsync(ActorSystem system)
|
|
{
|
|
var cluster = AkkaCluster.Get(system);
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (cluster.State.Members.Any(m => m.Status == MemberStatus.Up)) return;
|
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
cluster.State.Members.Any(m => m.Status == MemberStatus.Up).ShouldBeTrue(
|
|
$"{cluster.SelfAddress} never reached Up as its own seed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-sends until the inbox completes or the deadline passes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A ClusterClient establishes contact asynchronously and <c>buffer-size = 0</c> means anything
|
|
/// sent before that lands is dropped on the floor — by design. A single send would therefore
|
|
/// race the handshake and fail intermittently for a reason that is not a defect. Re-sending
|
|
/// weakens nothing: the control test re-sends on exactly the same schedule and must still
|
|
/// receive nothing.
|
|
/// </remarks>
|
|
/// <param name="target">Actor to send to.</param>
|
|
/// <param name="message">Builds the message for each attempt.</param>
|
|
/// <param name="inbox">Completion source the receiving capture actor resolves.</param>
|
|
/// <param name="timeout">How long to keep trying.</param>
|
|
/// <returns>The delivered message, or <see langword="null"/> if none arrived.</returns>
|
|
private static async Task<object?> SendUntilDeliveredAsync(
|
|
IActorRef target,
|
|
Func<object> message,
|
|
TaskCompletionSource<object> inbox,
|
|
TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (inbox.Task.IsCompleted) return await inbox.Task;
|
|
target.Tell(message());
|
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
return inbox.Task.IsCompleted ? await inbox.Task : null;
|
|
}
|
|
|
|
private static TaskCompletionSource<object> NewInbox() =>
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private static ImmutableHashSet<ActorPath> ReceptionistOf(ActorSystem system) =>
|
|
ImmutableHashSet.Create<ActorPath>(
|
|
new RootActorPath(AkkaCluster.Get(system).SelfAddress) / "system" / "receptionist");
|
|
|
|
private static async Task ShutdownAsync(ActorSystem? system)
|
|
{
|
|
if (system is null) return;
|
|
await system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
|
|
}
|
|
|
|
/// <summary>Completes an inbox with the first message it receives.</summary>
|
|
private sealed class CaptureActor : ReceiveActor
|
|
{
|
|
public static Props Props(TaskCompletionSource<object> sink) =>
|
|
Akka.Actor.Props.Create(() => new CaptureActor(sink));
|
|
|
|
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
|
|
}
|
|
}
|