fix(alarms): subscribe native alarms to un-gate the IAlarmSource feed

Phase B native alarms never fired end-to-end: GalaxyDriver suppresses OnAlarmEvent until
an alarm subscription exists (_alarmSubscriptions.Count > 0), but the runtime only attached
the OnAlarmEvent handler and never called SubscribeAlarmsAsync — so the central feed stayed
gated and no transition reached the Part 9 condition / /alerts. Unit tests passed because
they inject through the IAlarmSource seam directly; the deferred live /run surfaced it.

DriverHostActor computes per-driver alarm refs (alarm-bearing tags' FullNames) and hands them
via SetDesiredSubscriptions; DriverInstanceActor calls SubscribeAlarmsAsync for IAlarmSource
drivers on Connected entry and whenever alarm refs are pushed while Connected (the deploy path),
idempotent via a cached handle reset on detach so reconnect re-subscribes.
This commit is contained in:
Joseph Doherty
2026-06-15 00:42:43 -04:00
parent 063d004fda
commit 7f313df7a6
3 changed files with 142 additions and 4 deletions
@@ -64,6 +64,42 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
.FullReference.ShouldBe("tag-z");
}
/// <summary>
/// The native-alarm path is gated at the driver: an <see cref="IAlarmSource"/> suppresses
/// <c>OnAlarmEvent</c> until at least one alarm subscription exists (e.g. GalaxyDriver gates its
/// central feed). When the host pushes a <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>
/// carrying native-alarm refs to a <c>Connected</c> driver — the live deploy path — the actor must
/// call <see cref="IAlarmSource.SubscribeAlarmsAsync"/> with those refs to un-gate the feed, and must
/// NOT re-subscribe when the same set is redeployed (the feed is already un-gated).
/// </summary>
[Fact]
public async Task Alarm_subscription_is_established_when_alarm_refs_are_pushed_while_connected()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2)); // reached Connected
driver.SubscribeAlarmsCallCount.ShouldBe(0, "no alarm subscription before any alarm refs are pushed");
// A deploy delivers the desired set with a native-alarm ref while the driver is already Connected.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2));
driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" });
// Redeploying the same alarm set must NOT re-subscribe (idempotent — already un-gated). Round-trip a
// data Subscribe to flush the mailbox so the second SetDesiredSubscriptions is fully processed first.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-z" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.SubscribeAlarmsCallCount.ShouldBe(1, "an already-established alarm subscription is not re-issued");
}
/// <summary>
/// A driver that is NOT an <see cref="IAlarmSource"/> (only <see cref="ISubscribable"/>) connects
/// and serves data changes normally — <c>AttachAlarmSource</c> is a safe no-op (no crash) and no
@@ -266,10 +302,23 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
private int _subscribeAlarmsCallCount;
/// <summary>Number of <see cref="SubscribeAlarmsAsync"/> calls — proves the actor established the
/// native-alarm subscription that un-gates the feed (incremented on the actor dispatcher thread).</summary>
public int SubscribeAlarmsCallCount => Volatile.Read(ref _subscribeAlarmsCallCount);
/// <summary>The node set handed to the most recent <see cref="SubscribeAlarmsAsync"/> call.</summary>
public IReadOnlyList<string>? LastAlarmRefs { get; private set; }
/// <summary>Subscribes to alarm events for the specified node set.</summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
LastAlarmRefs = sourceNodeIds;
Interlocked.Increment(ref _subscribeAlarmsCallCount);
return Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
}
/// <summary>Cancels an alarm subscription.</summary>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>