fix(runtime): harden batch forwarding — detach pins, malformed-raise guard, struct items

This commit is contained in:
Joseph Doherty
2026-08-15 12:31:29 -04:00
parent 5c61d0b133
commit 8ef02506f7
3 changed files with 121 additions and 4 deletions
@@ -97,6 +97,10 @@ public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
// Deterministic half of the assertion: the per-tag handler was never attached at all. ExpectNoMsg
// below only proves nothing arrived within its window; this proves it structurally cannot.
driver.OnDataChangeSubscriberCount.ShouldBe(0, "a batch-capable driver must NOT get the per-tag handler");
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: true,
new DataChangeItem("tag-a", new DataValueSnapshot(1, OpcUaStatus.Good, ts, ts)),
@@ -172,6 +176,104 @@ public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>
/// Comms loss (<see cref="DriverInstanceActor.DisconnectObserved"/>, Connected → Reconnecting) detaches
/// the batch handler too. The <see cref="IDriver"/> object outlives the actor across a host respawn, so a
/// handler surviving the disconnect would push a burst produced by the dead transport into a stale actor.
/// </summary>
[Fact]
public async Task DisconnectObserved_detaches_batch_handler_so_late_bursts_are_dropped()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1);
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away"));
// Race window — once the disconnect is processed the handler is gone and a late burst is inert.
AwaitCondition(() => driver.OnDataChangeBatchSubscriberCount == 0, PresenceBudget);
var ts = new DateTime(2026, 8, 15, 13, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: true,
new DataChangeItem("tag-a", new DataValueSnapshot(42, OpcUaStatus.Good, ts, ts)));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Stopping the actor detaches the batch handler in <c>PostStop</c> — the same leak guard as the
/// per-tag path, and the one that matters when the host respawns a child around the same
/// <see cref="IDriver"/> instance.
/// </summary>
[Fact]
public async Task PostStop_detaches_batch_handler()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1);
Sys.Stop(actor);
AwaitCondition(() => driver.OnDataChangeBatchSubscriberCount == 0, PresenceBudget);
}
/// <summary>
/// A malformed raise from a buggy driver — a null payload, or one carrying no items — is dropped at
/// the handler and never reaches the mailbox. Unguarded, the null would throw inside the event
/// handler, restart the actor, and take the driver transport down with it; the still-attached handler
/// (count unchanged) and the burst that lands afterwards prove neither happened.
/// </summary>
[Fact]
public async Task Malformed_batch_raise_is_dropped_without_restarting_the_actor()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.FireBatch(includePerTagRaises: false /* no items — an empty burst */);
driver.FireNullBatch();
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1,
"a restart would have detached the handler in PostStop");
// …and the subscription is still live: a well-formed burst still lands.
var ts = new DateTime(2026, 8, 15, 14, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: false,
new DataChangeItem("tag-a", new DataValueSnapshot(5, OpcUaStatus.Good, ts, ts)));
var batch = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
batch.Items.Count.ShouldBe(1);
batch.Items[0].FullReference.ShouldBe("tag-a");
batch.Items[0].Value.ShouldBe(5);
}
/// <summary>
/// A <see cref="StubDriver"/> that implements <see cref="IBatchSubscribable"/> — it raises
/// <see cref="OnDataChangeBatch"/> once per burst AND (optionally, per the documented driver
@@ -222,5 +324,9 @@ public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, item.FullReference, item.Snapshot));
}
}
/// <summary>Raises the batch event with a null payload — what a buggy driver does, and what the
/// handler's guard exists to absorb.</summary>
public void FireNullBatch() => OnDataChangeBatch?.Invoke(this, null!);
}
}