-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBusStateMonitor.cs
More file actions
337 lines (303 loc) · 17.5 KB
/
Copy pathBusStateMonitor.cs
File metadata and controls
337 lines (303 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
using System;
using System.Threading;
using CanKit.Abstractions.API.Can;
using CanKit.Abstractions.API.Common.Definitions;
using CanKit.Pro.Actor;
namespace CanKit.Pro.Reliability
{
/// <summary>
/// Event args for <see cref="BusStateMonitor.StateChanged"/> (SRS FR-RAW-051): carries both the
/// previous and the newly-observed <see cref="BusState"/> so a subscriber can react to the
/// specific transition (e.g. only abort on entering <see cref="BusState.BusOff"/>, only resume
/// on leaving it).
/// </summary>
public sealed class BusStateChangedEventArgs : EventArgs
{
/// <summary>Creates the args for a transition from <paramref name="previous"/> to <paramref name="current"/>.</summary>
public BusStateChangedEventArgs(BusState previous, BusState current)
{
Previous = previous;
Current = current;
}
/// <summary>The last state observed before this transition.</summary>
public BusState Previous { get; }
/// <summary>The state observed now, which differs from <see cref="Previous"/>.</summary>
public BusState Current { get; }
}
/// <summary>
/// Pushes <see cref="ICanBus.BusState"/> transitions to a protocol instance so it can abort or
/// pause controlled transmissions on degradation (ErrWarning/ErrPassive/BusOff) and resume once
/// the bus recovers (SRS FR-RAW-051).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ICanBus.BusState"/> is a plain getter with no dedicated change event, and an
/// adapter's <see cref="ICanBus.ErrorFrameReceived"/>/<see cref="ICanBus.FaultOccurred"/> are
/// not guaranteed to fire on <i>every</i> transition. The reliable mechanism is therefore a
/// <b>self-rearming poll driven through the owning <see cref="IProtocolActor"/>'s own
/// <see cref="IProtocolActor.Schedule"/></b> (default 50 ms) rather than a
/// <see cref="System.Threading.Timer"/> or a free-running thread -- this keeps the monitor
/// inside the existing single-mailbox event-driven-actor model (FR-RAW-020..022) instead of
/// reintroducing a busy-loop/free-running-timer anti-pattern. The two bus events are subscribed
/// <i>additionally</i>, purely as low-latency hints: they trigger an immediate out-of-band
/// recheck (<see cref="IProtocolActor.Post"/>) so e.g. a <see cref="BusState.BusOff"/> is
/// observed near-instantly instead of waiting up to one poll interval. The hint deliberately
/// does not touch the poll timer; the self-rearming poll is the independent reliability floor.
/// </para>
/// <para>
/// <b>Hints are coalesced.</b> A bus-off or error-passive storm raises
/// <see cref="ICanBus.ErrorFrameReceived"/> far faster than any loop can drain it (thousands
/// per second on a shorted or badly terminated bus), so at most <b>one</b> un-run hint recheck
/// is ever outstanding in the mailbox: further hints arriving while it is queued are dropped
/// rather than posted. This is lossless with respect to what the monitor reports, because a
/// recheck is a <i>sample of a level</i> (<see cref="ICanBus.BusState"/>, a plain getter), not
/// the delivery of a queued event -- N back-to-back samples of an unchanged level yield exactly
/// what one sample yields. What is dropped is the redundant mailbox traffic, which would
/// otherwise starve the very protocol work the state change exists to abort. The gate is
/// released <i>before</i> the sample is taken, so a hint that races an in-flight recheck posts a
/// fresh one and the last hint of a storm is always followed by a sample taken after it.
/// </para>
/// <para>
/// <b>What coalescing does not promise:</b> the hints were never a transition log, and an error
/// frame is not a state transition. If the controller passes through ErrWarning and ErrPassive
/// on its way to BusOff faster than the loop samples, the intermediate levels are missed and
/// <see cref="StateChanged"/> reports one ErrActive -> BusOff edge -- exactly as it already
/// does when the hints are unavailable and the poll alone drives sampling. Every edge that
/// <i>is</i> sampled is still reported individually and in order, with
/// <see cref="BusStateChangedEventArgs.Previous"/> chained to the last reported state, so a
/// subscriber never sees a gap or a re-ordering. Sampling granularity remains tunable the way
/// it always was: shorten <c>pollInterval</c>.
/// </para>
/// <para>
/// <b>Loop-thread cost:</b> each poll tick reads <see cref="ICanBus.BusState"/> synchronously on
/// the actor's own loop thread. If a particular adapter's <c>BusState</c> getter is slow or
/// blocking, that stalls this protocol instance's loop for the duration -- a known, accepted
/// tradeoff of reusing the actor (which keeps state-change handling single-writer-safe) rather
/// than a bug to work around here.
/// </para>
/// <para>
/// <b>Lifetime:</b> the poll loop also stops on its own once the owning actor is disposed
/// (a re-arm then observes <see cref="ObjectDisposedException"/> and quietly ceases). Calling
/// <see cref="Dispose"/> is still required to detach the two bus event subscriptions, which are
/// independent of the actor's lifetime.
/// </para>
/// </remarks>
public sealed class BusStateMonitor : IDisposable
{
// Default poll cadence: fast enough that a BusOff is noticed within ~50 ms even if no
// ErrorFrame/Fault hint ever fires, cheap enough to be negligible on an otherwise-idle loop.
private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(50);
private readonly ICanBus _bus;
private readonly IProtocolActor _actor;
private readonly TimeSpan _pollInterval;
private readonly EventHandler<ICanErrorInfo> _errorHint;
private readonly EventHandler<Exception> _faultHint;
// Whether each hint subscription actually took: some adapters reject ErrorFrameReceived
// unless configured for it (e.g. AllowErrorInfo=false). A rejected hint is non-fatal -- the
// poll is the reliability floor -- so we degrade to poll-only and remember not to detach a
// subscription we never made.
private readonly bool _errorHintSubscribed;
private readonly bool _faultHintSubscribed;
// Last-observed state, as an int for lock-free Volatile access. Written only on the actor
// loop (poll tick / hint recheck), read from any thread via CurrentState. Because the loop
// is the single writer, it can trust its own last write without a lock.
private int _stateRaw;
// Coalescing gate for hint-driven rechecks: 1 while a hint recheck is queued-but-not-yet-run.
// Written from the bus's event thread (claim) and from the actor loop (release), hence
// interlocked rather than a plain bool.
private int _recheckPending;
private IDisposable? _pollHandle; // the currently scheduled poll tick; best-effort cancelled on Dispose
private int _disposed;
/// <summary>
/// Wraps <paramref name="bus"/> and drives its state polling through <paramref name="actor"/>.
/// </summary>
/// <param name="bus">The bus whose <see cref="ICanBus.BusState"/> is observed.</param>
/// <param name="actor">The protocol instance's actor; the poll runs on its loop (FR-RAW-020/051).</param>
/// <param name="pollInterval">
/// Poll cadence; must be > <see cref="TimeSpan.Zero"/> when given. Defaults to 50 ms.
/// </param>
public BusStateMonitor(ICanBus bus, IProtocolActor actor, TimeSpan? pollInterval = null)
{
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
_actor = actor ?? throw new ArgumentNullException(nameof(actor));
if (pollInterval is { } pi && pi <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(pollInterval), "Poll interval must be positive.");
_pollInterval = pollInterval ?? DefaultPollInterval;
// Baseline the current state synchronously, right here in the constructor. This is safe
// even without synchronization: it runs before any other thread can observe `this`, so
// there is no writer to race with yet. It also means CurrentState is meaningful the
// instant the constructor returns, before the first poll tick.
_stateRaw = (int)_bus.BusState;
_errorHint = OnBusHint;
_faultHint = OnFaultHint;
// Arm the first poll tick on the loop before subscribing any bus events. Posting
// (rather than scheduling directly) keeps all timer bookkeeping originating from the
// actor's own thread, consistent with how the rest of the monitor's state is touched.
// Doing this first means that if the actor is already disposed and Post throws, the
// constructor fails before any bus event handler is attached, so no partially
// constructed instance is left pinned by the bus.
_actor.Post(RearmPoll);
// Subscribe the hints best-effort: a controlled TX abort must not be prevented just
// because an adapter won't surface error frames. If a subscription throws (adapter
// configuration), fall back to poll-only for that channel.
try
{
_bus.ErrorFrameReceived += _errorHint;
_errorHintSubscribed = true;
}
catch
{
_errorHintSubscribed = false;
}
try
{
_bus.FaultOccurred += _faultHint;
_faultHintSubscribed = true;
}
catch
{
_faultHintSubscribed = false;
}
}
/// <summary>
/// The most recently observed <see cref="BusState"/>. Reflects the bus's actual state at
/// construction time and is updated on every observed transition.
/// </summary>
public BusState CurrentState => (BusState)Volatile.Read(ref _stateRaw);
/// <summary>
/// Raised on the actor's loop whenever the observed state differs from the last-seen one --
/// for both degrading (e.g. ErrActive → BusOff) and recovering (e.g. BusOff → ErrActive)
/// transitions, since a protocol needs to know when to resume, not only when to abort
/// (FR-RAW-051). Edge-triggered: never raised while the state is unchanged.
/// </summary>
public event EventHandler<BusStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public void Dispose()
{
// Idempotent: only the first caller runs the teardown. The poll loop itself also stops
// once it next observes _disposed != 0 (or the actor is gone); Dispose exists chiefly to
// detach the bus event subscriptions, which outlive the actor otherwise.
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
if (_errorHintSubscribed)
{
try { _bus.ErrorFrameReceived -= _errorHint; } catch { /* bus tearing down; nothing else to do */ }
}
if (_faultHintSubscribed)
{
try { _bus.FaultOccurred -= _faultHint; } catch { /* bus tearing down; nothing else to do */ }
}
// Best-effort cancel the outstanding poll (a tick already dispatched onto the loop may
// still run once, but it will see _disposed and not re-arm -- so the loop goes quiet
// within at most one poll interval).
Volatile.Read(ref _pollHandle)?.Dispose();
}
private void OnBusHint(object? sender, ICanErrorInfo e) => PostRecheck();
private void OnFaultHint(object? sender, Exception e) => PostRecheck();
// Latency optimization only: an immediate out-of-band recheck on the loop. Deliberately does
// NOT rearm/reset the poll timer -- the self-rearming poll is independent and remains the
// reliability floor; this merely shortens the observation latency of a transition.
//
// Runs on whatever thread the adapter raises its error/fault events from, i.e. the RX or
// driver thread, at error-frame rate. During a bus-off storm that is thousands of calls per
// second, so this method's only job is to be cheap and to keep the mailbox bounded.
private void PostRecheck()
{
if (Volatile.Read(ref _disposed) != 0)
return;
// Coalesce (see the type's remarks): if a recheck is already queued and has not run
// yet, it has not sampled BusState yet either, so it will observe everything this hint
// could have caused. Posting a second one would add mailbox depth and no information.
if (Interlocked.CompareExchange(ref _recheckPending, 1, 0) != 0)
return;
try
{
_actor.Post(HintRecheckOnLoop);
}
catch (ObjectDisposedException)
{
// Actor already disposed; the poll loop has (or will) stop on its own. Release the
// gate anyway: nothing will run HintRecheckOnLoop to release it, and leaving it
// latched would silently suppress hints if the actor ever became usable again.
Volatile.Write(ref _recheckPending, 0);
}
}
// The coalesced hint recheck, on the actor's loop. Releasing the gate *before* sampling is
// the whole correctness argument: a hint raised while this method is reading BusState (or
// while it is still sitting behind other mailbox work) then claims the gate again and posts
// a follow-up, so the final hint of a storm is always succeeded by a sample taken after it.
// Releasing afterwards would open a window in which a state change is hinted, dropped, and
// then only picked up by the next poll tick -- turning the hint's latency guarantee into a
// poll-interval one at exactly the moment it matters most.
private void HintRecheckOnLoop()
{
// Interlocked rather than Volatile.Write: this needs a full fence, so the BusState read
// inside RecheckOnLoop cannot be hoisted above the release and observe a pre-hint value
// that a racing hint then declines to re-post for.
Interlocked.Exchange(ref _recheckPending, 0);
RecheckOnLoop();
}
// The scheduled poll tick body. Structured so the next poll is re-armed even if the state
// read throws: a transient failure reading BusState must not permanently kill monitoring.
// The original exception still propagates out of this Schedule callback to surface via the
// actor's BackgroundExceptionOccurred (FR-RAW-023); RearmPoll swallows only its own
// ObjectDisposedException so a throwing finally can't mask that original exception.
private void PollTick()
{
try
{
RecheckOnLoop();
}
finally
{
RearmPoll();
}
}
private void RecheckOnLoop()
{
if (Volatile.Read(ref _disposed) != 0)
return;
RaiseIfChanged();
}
// Runs on the actor's loop, so it is the single writer of _stateRaw and StateChanged is
// raised serially with the rest of the instance's work (no re-entrancy, no lock needed).
private void RaiseIfChanged()
{
var previous = (BusState)_stateRaw; // loop is the sole writer -> trust the last write
var current = _bus.BusState; // synchronous getter, on this loop thread (see remarks)
if (current == previous)
return;
Volatile.Write(ref _stateRaw, (int)current);
// Re-check disposal right before notifying: Dispose() runs on an arbitrary caller
// thread and can complete while this method (on the actor's loop thread) was busy
// reading BusState above, so the single guard in RecheckOnLoop's entry is not enough
// to prevent a StateChanged after the monitor has been disposed.
if (Volatile.Read(ref _disposed) != 0)
return;
StateChanged?.Invoke(this, new BusStateChangedEventArgs(previous, current));
}
private void RearmPoll()
{
if (Volatile.Read(ref _disposed) != 0)
return;
try
{
var handle = _actor.Schedule(_pollInterval, PollTick);
Volatile.Write(ref _pollHandle, handle);
// Dispose() may have run concurrently between the check above and Schedule
// returning, in which case it could have missed cancelling this handle. Re-check
// and best-effort cancel it ourselves so the poll loop still quiets down promptly.
if (Volatile.Read(ref _disposed) != 0)
{
handle.Dispose();
}
}
catch (ObjectDisposedException)
{
// The owning actor was disposed concurrently. Monitoring simply goes quiet -- we
// must not rethrow here, both so the poll loop ends cleanly and so, when called from
// PollTick's finally, this cannot mask an in-flight exception from the poll body.
}
}
}
}