review(Core.ScriptedAlarms): stop shelving timer on failed reload + drop dead branch

Re-review at 7286d320. -015: dispose shelving timer at top of LoadAsync so a failed
reload doesn't leave it firing against partially-cleared state + test. -014: make
pendingEmissions required (removes unreachable fire-under-gate branch that could
reintroduce the -003 deadlock).
This commit is contained in:
Joseph Doherty
2026-06-19 11:21:35 -04:00
parent 621d00e455
commit 272a9da61e
3 changed files with 186 additions and 14 deletions
@@ -184,6 +184,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Stop the prior shelving timer BEFORE clearing _alarms so no in-progress
// tick can observe a half-cleared dictionary. Moving the dispose here also
// means a failed reload (compile errors, store failures) stops the timer
// even though execution never reaches the _shelvingTimer = new Timer(...)
// assignment at the bottom of the try block. Without this, the old timer
// keeps firing against the partially-cleared _alarms until Dispose() is
// eventually called — not a permanent leak, but an unexpected side effect
// during the window. (Core.ScriptedAlarms-015)
_shelvingTimer?.Dispose();
_shelvingTimer = null;
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
@@ -281,13 +292,10 @@ public sealed class ScriptedAlarmEngine : IDisposable
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
// Dispose any previously-created timer before reassigning; a second LoadAsync
// call without this would leave two timers firing against the same engine.
// (Core.ScriptedAlarms-002)
_shelvingTimer?.Dispose();
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
// that have passed their UnshelveAtUtc.
// that have passed their UnshelveAtUtc. The prior timer was already disposed
// at the START of this try block (Core.ScriptedAlarms-015), so _shelvingTimer
// is null here; no double-dispose risk. (Core.ScriptedAlarms-002, -015)
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
@@ -480,9 +488,16 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// them after releasing <c>_evalGate</c> — keeping subscriber callbacks
/// outside the gate. (Core.ScriptedAlarms-003)
/// </summary>
/// <remarks>
/// Every caller (LoadAsync and ReevaluateAsync) owns a <c>pending</c> list and
/// passes it here; emissions are always deferred to after the gate is released.
/// The parameter is required (non-nullable) to make this contract explicit and
/// prevent a future caller from accidentally firing events under the gate.
/// (Core.ScriptedAlarms-014)
/// </remarks>
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct,
List<ScriptedAlarmEvent>? pendingEmissions = null)
List<ScriptedAlarmEvent> pendingEmissions)
{
// Look up (or lazily allocate) the per-alarm scratch and refill its read cache
// in place. The dictionary + context survive across evaluations so the hot path
@@ -526,11 +541,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
if (result.Emission != EmissionKind.None)
{
var evt = BuildEmission(state, result.State, result.Emission);
if (evt is not null)
{
if (pendingEmissions is not null) pendingEmissions.Add(evt);
else FireEvent(evt); // LoadAsync path: no caller-supplied list, fire here.
}
if (evt is not null) pendingEmissions.Add(evt);
}
return result.State;
}