fix(runtime): in-place ApplyDelta releases and re-subscribes data + alarms (#663)

A Connected ApplyDelta called ReinitializeAsync and kept the actor's data and
native-alarm handles, though every driver's reinit drops the driver-side
subscriptions behind them (#630). The reconcile only re-subscribes a missing
handle, so the driver went silent until a reconnect. Release both at the
driver before the reinit and re-subscribe both in a finally. Unreachable from
deploys since #516; pinned by a two-case test, each case failing against a
different wrong fix.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 07:49:56 -04:00
parent 83d129d1bd
commit 39c5af3d7f
4 changed files with 158 additions and 8 deletions
@@ -881,9 +881,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Sender.Tell(new ApplyResult(true, "config adopted; reinitializing", msg.Correlation));
}
/// <summary>Adopt a new config while Connected, in place, via <see cref="IDriver.ReinitializeAsync"/>.
/// <para>#663: every driver's reinit releases its device/session state (#630), so the driver-side data and
/// native-alarm subscriptions do not survive it — but this handler used to keep both handles, and the
/// health-poll reconcile only re-subscribes a MISSING handle, so the driver went silent until a
/// reconnect or respawn. Both subscriptions are now released explicitly BEFORE the reinit (explicitly,
/// not just by nulling the handles, so a driver whose reinit keeps its subscriptions is not left
/// holding two) and re-established after it — in a <c>finally</c>, because a failed reinit has already
/// lost them too. Reinit stays the adoption path rather than shutdown + initialise: Calculation and
/// Galaxy take their config at construction and ignore <see cref="IDriver.InitializeAsync"/>'s JSON.</para>
/// <para>Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).</para></summary>
private async Task HandleApplyDeltaAsync(ApplyDelta msg)
{
var replyTo = Sender;
await UnsubscribeAsync();
await ReleaseAlarmSubscriptionAsync();
try
{
await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
@@ -900,6 +912,37 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
replyTo.Tell(new ApplyResult(false, ex.Message, msg.Correlation));
}
finally
{
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
}
}
/// <summary>Release the native-alarm subscription at the driver (best-effort, bounded) and drop the
/// actor's handler + handle. The in-place reinit counterpart of the data <see cref="UnsubscribeAsync"/>
/// (#663).</summary>
private async Task ReleaseAlarmSubscriptionAsync()
{
if (_driver is IAlarmSource src && _alarmSubscriptionHandle is { } handle)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _invoker.ExecuteAsync(
DriverCapability.AlarmSubscribe,
_driverInstanceId,
async ct => await src.UnsubscribeAlarmsAsync(handle, ct),
cts.Token);
}
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: releasing the native-alarm subscription ({Diag}) threw (continuing)",
_driverInstanceId, handle.DiagnosticId);
}
}
DetachAlarmSource();
}
private async Task HandleWriteAsync(WriteAttribute msg)