Skip to content

Commit 97bd796

Browse files
authored
fix: race conditions in ble observer (#98)
* feat: Add regression tests for the observer * fix: Ensure BLE observer is thread save * chore: Apply review feedback
1 parent 1b3a673 commit 97bd796

7 files changed

Lines changed: 222 additions & 64 deletions

File tree

src/Darp.Ble/BleObserverExtensions.cs

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,7 @@ public static class BleObserverExtensions
1616
public static IObservable<IGapAdvertisement> OnAdvertisement(this IBleObserver observer)
1717
{
1818
ArgumentNullException.ThrowIfNull(observer);
19-
return Observable.Create<IGapAdvertisement>(advObserver =>
20-
observer.OnAdvertisement(
21-
advObserver,
22-
static (advObserver, advertisement) => advObserver.OnNext(advertisement)
23-
)
24-
);
25-
}
26-
27-
/// <summary> Register a callback called when an advertisement was received </summary>
28-
/// <param name="bleObserver">The instance of <see cref="IBleObserver"/> that will monitor BLE advertisements.</param>
29-
/// <param name="onAdvertisement"> The callback </param>
30-
/// <returns> A disposable to unsubscribe the callback </returns>
31-
public static IDisposable OnAdvertisement(this IBleObserver bleObserver, Action<IGapAdvertisement> onAdvertisement)
32-
{
33-
ArgumentNullException.ThrowIfNull(bleObserver);
34-
return bleObserver.OnAdvertisement(onAdvertisement, static (action, advertisement) => action(advertisement));
19+
return Observable.Create<IGapAdvertisement>(advObserver => observer.OnAdvertisement(advObserver.OnNext));
3520
}
3621

3722
/// <summary> Publish the observer to allow observing advertisements without having to start/stop observation manually </summary>
@@ -43,10 +28,7 @@ public static IConnectableObservable<IGapAdvertisement> Publish(this IBleObserve
4328

4429
IObservable<IGapAdvertisement> inner = Observable.Create<IGapAdvertisement>(async observer =>
4530
{
46-
IDisposable unhook = bleObserver.OnAdvertisement(
47-
observer,
48-
onAdvertisement: static (observer, adv) => observer.OnNext(adv)
49-
);
31+
IDisposable unhook = bleObserver.OnAdvertisement(onAdvertisement: observer.OnNext);
5032

5133
await bleObserver.StartObservingAsync().ConfigureAwait(false);
5234

src/Darp.Ble/IBleObserver.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@ public interface IBleObserver
2525
bool Configure(BleObservationParameters parameters);
2626

2727
/// <summary> Register a callback called when an advertisement was received </summary>
28-
/// <param name="state"> A state to be passed to the callback </param>
2928
/// <param name="onAdvertisement"> The callback </param>
30-
/// <typeparam name="T"> The type of the state </typeparam>
3129
/// <returns> A disposable to unsubscribe the callback </returns>
32-
IDisposable OnAdvertisement<T>(T state, Action<T, IGapAdvertisement> onAdvertisement);
30+
IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement);
3331

3432
/// <summary> Start observing for advertisements. </summary>
3533
/// <param name="cancellationToken"> The CancellationToken to cancel the initial starting process </param>

src/Darp.Ble/Implementation/BleObserver.cs

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
using System.Diagnostics;
21
using System.Reactive.Disposables;
32
using Darp.Ble.Data;
43
using Darp.Ble.Exceptions;
54
using Darp.Ble.Gap;
5+
using Darp.Ble.Utils;
66
using Microsoft.Extensions.Logging;
77
#if !NET9_0_OR_GREATER
88
using Lock = System.Object;
@@ -22,13 +22,14 @@ internal enum ObserverState
2222
/// <summary> The ble observer </summary>
2323
/// <param name="device"> The ble device </param>
2424
/// <param name="logger"> The logger </param>
25-
public abstract class BleObserver(BleDevice device, ILogger<BleObserver> logger) : IAsyncDisposable, IBleObserver
25+
public abstract class BleObserver(BleDevice device, ILogger<BleObserver> logger) : IBleObserver, IAsyncDisposable
2626
{
2727
private readonly BleDevice _bleDevice = device;
28-
private readonly List<Action<IGapAdvertisement>> _actions = [];
29-
private readonly Lock _lock = new();
30-
private readonly SemaphoreSlim _observationStartSemaphore = new(1, 1);
31-
private ObserverState _observerState = ObserverState.Stopped;
28+
private readonly SemaphoreSlim _startStopSemaphore = new(1, 1);
29+
private readonly Lock _handlersLock = new();
30+
private Action<IGapAdvertisement>[] _handlers = [];
31+
32+
private volatile ObserverState _observerState = ObserverState.Stopped;
3233

3334
/// <summary> The logger </summary>
3435
protected ILogger<BleObserver> Logger { get; } = logger;
@@ -54,28 +55,40 @@ public abstract class BleObserver(BleDevice device, ILogger<BleObserver> logger)
5455
/// <inheritdoc />
5556
public bool Configure(BleObservationParameters parameters)
5657
{
57-
if (_observerState is not ObserverState.Stopped)
58+
ObjectDisposedException.ThrowIf(_bleDevice.IsDisposing, nameof(BleObserver));
59+
60+
if (!_startStopSemaphore.Wait(0))
5861
return false;
59-
Parameters = parameters;
60-
return true;
62+
try
63+
{
64+
if (_observerState is not ObserverState.Stopped)
65+
return false;
66+
Parameters = parameters;
67+
return true;
68+
}
69+
finally
70+
{
71+
_startStopSemaphore.Release();
72+
}
6173
}
6274

6375
/// <inheritdoc />
6476
public async Task StartObservingAsync(CancellationToken cancellationToken = default)
6577
{
6678
ObjectDisposedException.ThrowIf(_bleDevice.IsDisposing, nameof(BleObserver));
6779

68-
await _observationStartSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
80+
await _startStopSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
6981
try
7082
{
7183
if (_observerState is ObserverState.Observing)
7284
return;
85+
if (_observerState is not ObserverState.Stopped)
86+
throw new InvalidOperationException($"Observer is in invalid state {_observerState}");
7387

74-
Debug.Assert(_observerState == ObserverState.Stopped);
7588
_observerState = ObserverState.Starting;
7689
await StartObservingAsyncCore(cancellationToken).ConfigureAwait(false);
7790
_observerState = ObserverState.Observing;
78-
Logger.LogTrace("Started advertising observation");
91+
Logger.LogObserverStarted();
7992
}
8093
catch (Exception e) when (e is not BleObservationStartException)
8194
{
@@ -85,28 +98,34 @@ public async Task StartObservingAsync(CancellationToken cancellationToken = defa
8598
}
8699
finally
87100
{
88-
_observationStartSemaphore.Release();
101+
_startStopSemaphore.Release();
89102
}
90103
}
91104

92105
/// <inheritdoc />
93-
public IDisposable OnAdvertisement<T>(T state, Action<T, IGapAdvertisement> onAdvertisement)
106+
public IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement)
94107
{
95108
ObjectDisposedException.ThrowIf(_bleDevice.IsDisposing, nameof(BleObserver));
96-
Action<IGapAdvertisement> action = advertisement => onAdvertisement(state, advertisement);
97-
lock (_lock)
109+
110+
// Extend handlers list
111+
lock (_handlersLock)
98112
{
99-
_actions.Add(action);
113+
Action<IGapAdvertisement>[] oldHandlers = _handlers;
114+
var newArr = new Action<IGapAdvertisement>[oldHandlers.Length + 1];
115+
Array.Copy(oldHandlers, newArr, oldHandlers.Length);
116+
newArr[^1] = onAdvertisement;
117+
Volatile.Write(ref _handlers, newArr);
100118
}
101119

102120
return Disposable.Create(
103-
(this, action),
121+
(this, onAdvertisement),
104122
static tuple =>
105123
{
106-
(BleObserver bleObserver, Action<IGapAdvertisement> action) = tuple;
107-
lock (bleObserver._lock)
124+
(BleObserver self, Action<IGapAdvertisement> handler) = tuple;
125+
lock (self._handlersLock)
108126
{
109-
bleObserver._actions.Remove(action);
127+
if (Helpers.TryRemoveImmutable(self._handlers, handler, out var newHandlers))
128+
Volatile.Write(ref self._handlers, newHandlers);
110129
}
111130
}
112131
);
@@ -116,19 +135,25 @@ public IDisposable OnAdvertisement<T>(T state, Action<T, IGapAdvertisement> onAd
116135
/// <param name="advertisement"> The advertisement </param>
117136
protected void OnNext(IGapAdvertisement advertisement)
118137
{
119-
lock (_lock)
138+
// Try to suppress receival of advertisements after stopping/disposal
139+
// Best-effort only. No thread safety guarantees
140+
if (_bleDevice.IsDisposing || _observerState is ObserverState.Stopping or ObserverState.Stopped)
141+
return;
142+
143+
// Taking the current snapshot of the handlers.
144+
// In case of an unsubscription of a handler we might have taken the reference here already and call it afterward.
145+
// This is a known tradeoff
146+
Action<IGapAdvertisement>[] handlers = Volatile.Read(ref _handlers);
147+
foreach (Action<IGapAdvertisement> handler in handlers)
120148
{
121-
for (int i = _actions.Count - 1; i >= 0; i--)
149+
try
122150
{
123-
try
124-
{
125-
var onAdvertisement = _actions[i];
126-
onAdvertisement(advertisement);
127-
}
128-
catch (Exception e)
129-
{
130-
Logger.LogWarning(e, "Exception while handling advertisement event: {Message}", e.Message);
131-
}
151+
handler(advertisement);
152+
}
153+
catch (Exception e)
154+
{
155+
// An exception inside the handler should not crash all observers. Logging and ignoring ...
156+
Logger.LogObservationErrorDuringAdvertisementHandling(e);
132157
}
133158
}
134159
}
@@ -141,24 +166,37 @@ protected void OnNext(IGapAdvertisement advertisement)
141166
/// <inheritdoc />
142167
public async Task StopObservingAsync()
143168
{
144-
await _observationStartSemaphore.WaitAsync().ConfigureAwait(false);
169+
await _startStopSemaphore.WaitAsync().ConfigureAwait(false);
145170
try
146171
{
147172
// Return early if
148173
// Stopped -> Nothing to do
149174
// Stopping -> Some recursive call has lead to us being here
150175
if (_observerState is ObserverState.Stopping or ObserverState.Stopped)
151176
return;
177+
if (_observerState is not ObserverState.Observing)
178+
throw new InvalidOperationException($"Observer is in invalid state {_observerState}");
152179

153-
Debug.Assert(_observerState == ObserverState.Observing);
154180
_observerState = ObserverState.Stopping;
155-
await StopObservingAsyncCore().ConfigureAwait(false);
156-
Logger.LogTrace("Stopped advertising observation");
181+
182+
try
183+
{
184+
await StopObservingAsyncCore().ConfigureAwait(false);
185+
}
186+
catch (Exception e)
187+
{
188+
Logger.LogObserverErrorDuringStopping(e);
189+
// In case of an error when stopping we assume we are still observing
190+
// Not ideal, but better than to wait for ever
191+
_observerState = ObserverState.Observing;
192+
throw;
193+
}
194+
Logger.LogObserverStopped();
157195
_observerState = ObserverState.Stopped;
158196
}
159197
finally
160198
{
161-
_observationStartSemaphore.Release();
199+
_startStopSemaphore.Release();
162200
}
163201
}
164202

@@ -171,11 +209,11 @@ public async ValueTask DisposeAsync()
171209
{
172210
GC.SuppressFinalize(this);
173211
await StopObservingAsync().ConfigureAwait(false);
174-
lock (_lock)
212+
lock (_handlersLock)
175213
{
176-
_actions.Clear();
214+
_handlers = [];
177215
}
178-
_observationStartSemaphore.Dispose();
216+
_startStopSemaphore.Dispose();
179217
await DisposeAsyncCore().ConfigureAwait(false);
180218
}
181219

src/Darp.Ble/Logging.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,16 @@ internal static partial class Logging
1616

1717
[LoggerMessage(Level = LogLevel.Trace, Message = "Ble server peer '{Address}' disposed!")]
1818
public static partial void LogBleServerPeerDisposed(this ILogger logger, BleAddress address);
19+
20+
[LoggerMessage(Level = LogLevel.Trace, Message = "Started advertising observation")]
21+
public static partial void LogObserverStarted(this ILogger logger);
22+
23+
[LoggerMessage(Level = LogLevel.Trace, Message = "Stopped advertising observation")]
24+
public static partial void LogObserverStopped(this ILogger logger);
25+
26+
[LoggerMessage(Level = LogLevel.Error, Message = "Exception while handling advertisement event")]
27+
public static partial void LogObservationErrorDuringAdvertisementHandling(this ILogger logger, Exception e);
28+
29+
[LoggerMessage(Level = LogLevel.Error, Message = "Exception while stopping observation")]
30+
public static partial void LogObserverErrorDuringStopping(this ILogger logger, Exception e);
1931
}

src/Darp.Ble/Utils/Helpers.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace Darp.Ble.Utils;
4+
5+
internal static class Helpers
6+
{
7+
public static bool TryRemoveImmutable<T>(T[] array, T item, [NotNullWhen(true)] out T[]? newArray)
8+
{
9+
// Check if there is a handler to remove
10+
int handlerIndex = Array.IndexOf(array, item);
11+
if (handlerIndex < 0)
12+
{
13+
newArray = null;
14+
return false;
15+
}
16+
17+
Span<T> arraySpan = array;
18+
if (arraySpan.Length == 1)
19+
{
20+
newArray = [];
21+
return true;
22+
}
23+
24+
// Remove the handler from the array
25+
newArray = new T[arraySpan.Length - 1];
26+
if (handlerIndex > 0)
27+
arraySpan[..handlerIndex].CopyTo(newArray);
28+
if (handlerIndex < arraySpan.Length - 1)
29+
arraySpan[(handlerIndex + 1)..].CopyTo(newArray.AsSpan()[handlerIndex..]);
30+
return true;
31+
}
32+
}

0 commit comments

Comments
 (0)