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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user