Skip to content

Commit 6aec2f4

Browse files
fix(akka): defensive ctor + per-actor mailbox in the harbour sample
Two related fixes for the global-default-mailbox bootstrap NRE: - BowireAkkaExtension.ctor now wraps the DeadLetterListener spawn + EventStream subscribe in try/catch. When the BowireTapMailbox is wired as the *global* default-mailbox, it gets instantiated for the root guardian during bootstrap and triggers Apply on the extension before the actor system is itself navigable, which NREs inside SystemActorOf. We swallow that one path and leave _deadLetterListener null — the live mailbox tap still works, only dead-letter capture goes silent in that config. - Harbour sample now uses the surgical per-actor opt-in instead of the global default-mailbox swap. Defines a named mailbox (akka.actor.bowire-tap) and tags the three application actors with .WithMailbox(...). Avoids the bootstrap entanglement entirely and is what the README recommends for production use.
1 parent 37de317 commit 6aec2f4

2 files changed

Lines changed: 46 additions & 14 deletions

File tree

samples/Kuestenlogik.Bowire.Protocol.Akka.Sample/Program.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,16 @@
2121
// 2. Pick the "Akka.NET" protocol tab
2222
// 3. Stream the Tap → MonitorMessages method
2323

24+
// Define a NAMED mailbox config (akka.actor.bowire-tap) and opt our
25+
// three application actors into it via Props.WithMailbox below — this
26+
// is the surgical opt-in pattern from the plugin README. Setting the
27+
// default-mailbox globally would also rewrap the system's own root
28+
// guardian + dead-letters mailbox during bootstrap, which loads the
29+
// BowireAkkaExtension before the actor system is navigable.
2430
const string TapHocon = """
25-
akka.actor.default-mailbox.mailbox-type = "Kuestenlogik.Bowire.Protocol.Akka.BowireTapMailbox, Kuestenlogik.Bowire.Protocol.Akka"
31+
akka.actor.bowire-tap = {
32+
mailbox-type = "Kuestenlogik.Bowire.Protocol.Akka.BowireTapMailbox, Kuestenlogik.Bowire.Protocol.Akka"
33+
}
2634
""";
2735

2836
var builder = WebApplication.CreateBuilder(args);
@@ -38,9 +46,11 @@
3846

3947
// Master gets bound to the dock after construction (forward-decl), so
4048
// PortCallClosed can flow back. Order: crane → master → dock → bind.
41-
var crane = system.ActorOf(CraneActor.Build(), "crane-A1");
42-
var master = system.ActorOf(HarborMasterActor.Build(), "harbor-master");
43-
var dock = system.ActorOf(DockActor.Build(crane, master), "dock-1");
49+
// Each actor opts into the BowireTapMailbox via WithMailbox so the
50+
// system-internal actors keep their default mailbox.
51+
var crane = system.ActorOf(CraneActor.Build().WithMailbox("akka.actor.bowire-tap"), "crane-A1");
52+
var master = system.ActorOf(HarborMasterActor.Build().WithMailbox("akka.actor.bowire-tap"), "harbor-master");
53+
var dock = system.ActorOf(DockActor.Build(crane, master).WithMailbox("akka.actor.bowire-tap"), "dock-1");
4454
master.Tell(new BindDock(dock));
4555

4656
// Background scheduler — random ship every 2 s.

src/Kuestenlogik.Bowire.Protocol.Akka/BowireAkkaExtension.cs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public sealed class BowireAkkaExtension : IExtension
3636
{
3737
private readonly object _lock = new();
3838
private readonly List<Channel<TappedMessage>> _subscribers = [];
39-
private readonly IActorRef _deadLetterListener;
39+
private readonly IActorRef? _deadLetterListener;
4040
private readonly string _deadLetterPath;
4141

4242
/// <summary>The actor system this extension instance belongs to.</summary>
@@ -47,14 +47,33 @@ internal BowireAkkaExtension(ExtendedActorSystem system)
4747
System = system;
4848
_deadLetterPath = system.DeadLetters.Path.ToString();
4949

50-
// Subscribe a lightweight internal actor to the EventStream for
51-
// DeadLetter notifications. We use an actor (rather than a raw
52-
// delegate) because Akka.NET's EventStream API is actor-based —
53-
// ActorOf gives us automatic lifecycle handling.
54-
_deadLetterListener = system.SystemActorOf(
55-
Props.Create(() => new DeadLetterListener(this)),
56-
"bowire-deadletter-listener");
57-
system.EventStream.Subscribe(_deadLetterListener, typeof(DeadLetter));
50+
// Spawn a private system actor that bridges the EventStream's
51+
// actor-based DeadLetter notifications to PublishDeadLetter.
52+
//
53+
// Wrapped in try/catch: if the BowireTapMailbox is configured
54+
// as the *global* default mailbox (akka.actor.default-mailbox),
55+
// the mailbox is created for the root guardian during bootstrap
56+
// and triggers Apply on this extension before the actor system
57+
// is itself navigable. SystemActorOf NREs in that path. The
58+
// surgical opt-in pattern (per-actor `Props.WithMailbox` or a
59+
// named mailbox config like `akka.actor.bowire-tap`) avoids the
60+
// bootstrap entanglement, but we degrade gracefully so a global
61+
// default-mailbox swap still gives you the live tap stream
62+
// (just without dead-letter capture).
63+
try
64+
{
65+
_deadLetterListener = system.SystemActorOf(
66+
Props.Create(() => new DeadLetterListener(this)),
67+
"bowire-deadletter-listener");
68+
system.EventStream.Subscribe(_deadLetterListener, typeof(DeadLetter));
69+
}
70+
catch
71+
{
72+
// System not navigable yet (root-guardian bootstrap path).
73+
// Live mailbox taps still work; dead-letter capture is the
74+
// only thing missing in that mode.
75+
_deadLetterListener = null;
76+
}
5877

5978
// Tear down the subscription when the system shuts down so we
6079
// don't leak the EventStream registration. IExtension has no
@@ -63,7 +82,10 @@ internal BowireAkkaExtension(ExtendedActorSystem system)
6382
{
6483
try
6584
{
66-
System.EventStream.Unsubscribe(_deadLetterListener, typeof(DeadLetter));
85+
if (_deadLetterListener is { } listener)
86+
{
87+
System.EventStream.Unsubscribe(listener, typeof(DeadLetter));
88+
}
6789
}
6890
catch
6991
{

0 commit comments

Comments
 (0)