Skip to content

Commit bbc293b

Browse files
authored
fix: Properly propagate transport errors (#110)
Summary Right now, if a transport (e.g., the H4Transport) fails, we only log the error but never propagate it to the user, meaning, e.g., an OnAdvertisement call might hang forever, and the user has no way of knowing. This PR introduces error propagation to the BleObserver. Changes Added an onError for HciTransports Added onError to the OnAdvertisement of IBleObserver Raising errors in HciTransports, Windows Observers, and Android observers Testing Added unit tests Impact Breaking the onAdvertisement function OnAdvertisement (IObservable) might now return errors
1 parent deb60ca commit bbc293b

14 files changed

Lines changed: 309 additions & 24 deletions

File tree

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ dotnet_diagnostic.CS9124.severity = error
4040
dotnet_diagnostic.CA1819.severity = none
4141
dotnet_diagnostic.CA1716.severity = none
4242
dotnet_diagnostic.CA1043.severity = none
43+
# Do not warn catching general exception types. With c#, you never know which exceptions occur
44+
dotnet_diagnostic.CA1031.severity = none
4345

4446
# ReSharper properties
4547
resharper_formatter_off_tag = @formatter:off

src/Darp.Ble.Android/AndroidBleObserver.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ protected override Task StartObservingAsyncCore(CancellationToken cancellationTo
4040
},
4141
failure =>
4242
{
43-
Logger.LogError("Scan failure because of {Failure}", failure);
44-
_ = StopObservingAsync();
43+
if (_scanCallback is not null)
44+
{
45+
_bluetoothLeScanner.StopScan(_scanCallback);
46+
_scanCallback.Dispose();
47+
_scanCallback = null;
48+
}
49+
_ = OnErrorAsync(
50+
new BleObservationException(this, $"Scan failed because of {failure}", innerException: null)
51+
);
4552
}
4653
);
4754
using var settingsBuilder = new ScanSettings.Builder();

src/Darp.Ble.Hci/Host/HciHost.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515

1616
namespace Darp.Ble.Hci.Host;
1717

18+
/// <summary> Provides the fatal exception raised by the transport layer. </summary>
19+
public sealed class HciTransportFailedEventArgs(Exception exception) : EventArgs
20+
{
21+
/// <summary> The fatal transport exception. </summary>
22+
public Exception Exception { get; } = exception;
23+
}
24+
1825
/// <summary>
1926
/// The <see cref="HciHost"/> is responsible for all host-related commands.
2027
/// </summary>
@@ -29,6 +36,9 @@ public sealed partial class HciHost(HciDevice hciDevice, ITransportLayer transpo
2936
private AclPacketQueue? _leAclPacketQueue;
3037
private bool _isResetDoneAtLeastOnce;
3138

39+
/// <summary> An event that notifies when the transport has failed </summary>
40+
public event EventHandler<HciTransportFailedEventArgs>? TransportFailed;
41+
3242
/// <summary> The HCI Device </summary>
3343
public HciDevice Device { get; } = hciDevice;
3444

@@ -44,7 +54,7 @@ public sealed partial class HciHost(HciDevice hciDevice, ITransportLayer transpo
4454
public async Task ResetAsync(CancellationToken token)
4555
{
4656
ObjectDisposedException.ThrowIf(Device.IsDisposed, this);
47-
await _transportLayer.InitializeAsync(OnReceivedPacket, token).ConfigureAwait(false);
57+
await _transportLayer.InitializeAsync(OnReceivedPacket, OnTransportFailed, token).ConfigureAwait(false);
4858
Activity? activity = Logging.StartInitializeHciHostActivity();
4959
try
5060
{
@@ -234,6 +244,11 @@ when HciAclPacket.TryReadLittleEndian(packet.Pdu, out HciAclPacket x):
234244
}
235245
}
236246

247+
private void OnTransportFailed(Exception exception)
248+
{
249+
TransportFailed?.Invoke(this, new HciTransportFailedEventArgs(exception));
250+
}
251+
237252
private void OnReceivedHciEventPacket(HciPacketEvent packet)
238253
{
239254
if (

src/Darp.Ble.Hci/Transport/H4TransportLayer.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,57 @@ public sealed class H4TransportLayer(string portName, ILogger<H4TransportLayer>?
2626
private readonly SerialPort _serialPort = new(portName);
2727
private readonly Channel<IHciPacket> _txQueue = Channel.CreateUnbounded<IHciPacket>();
2828
private readonly CancellationTokenSource _cancelSource = new();
29+
private Action<Exception>? _onError;
2930
private bool _isDisposing;
31+
private int _isFaulted;
3032
private Task? _rxTask;
3133
private Task? _txTask;
3234

3335
private CancellationToken StopToken => _cancelSource.Token;
3436

3537
/// <inheritdoc />
36-
public ValueTask InitializeAsync(Action<HciPacket> onReceived, CancellationToken cancellationToken)
38+
public ValueTask InitializeAsync(
39+
Action<HciPacket> onReceived,
40+
Action<Exception> onError,
41+
CancellationToken cancellationToken
42+
)
3743
{
3844
if (_txTask is not null || _rxTask is not null)
3945
throw new InvalidOperationException("Initialization can only be done once");
46+
_onError = onError;
4047
_serialPort.Open();
4148
_txTask = Task.Run(RunTx, cancellationToken);
4249
_rxTask = Task.Run(() => RunRx(onReceived), cancellationToken);
4350
return ValueTask.CompletedTask;
4451
}
4552

53+
private void ReportTransportFailure(Exception exception, string direction)
54+
{
55+
if (Interlocked.Exchange(ref _isFaulted, 1) != 0)
56+
return;
57+
58+
_logger?.LogH4TransportWithError(exception, direction, exception.Message);
59+
try
60+
{
61+
_cancelSource.Cancel();
62+
}
63+
catch
64+
{
65+
// Ignore cancellation errors while reporting a fatal transport failure
66+
}
67+
68+
try
69+
{
70+
_serialPort.Close();
71+
}
72+
catch
73+
{
74+
// Ignore close errors while reporting a fatal transport failure
75+
}
76+
77+
_onError?.Invoke(exception);
78+
}
79+
4680
private async Task RunTx()
4781
{
4882
try
@@ -77,7 +111,7 @@ private async Task RunTx()
77111
_logger?.LogH4TransportDisconnected("Tx");
78112
return;
79113
}
80-
_logger?.LogH4TransportWithError(e, "Tx", e.Message);
114+
ReportTransportFailure(e, "Tx");
81115
}
82116
#pragma warning restore CA1031
83117
}
@@ -130,7 +164,7 @@ private async Task RunRx(Action<HciPacket> onReceived)
130164
return;
131165
}
132166

133-
_logger?.LogH4TransportWithError(e, "Rx", e.Message);
167+
ReportTransportFailure(e, "Rx");
134168
}
135169
#pragma warning restore CA1031
136170
}

src/Darp.Ble.Hci/Transport/ITransportLayer.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@ public interface ITransportLayer : IAsyncDisposable
1010
void Enqueue(IHciPacket packet);
1111

1212
/// <summary> Initialize the transport layer </summary>
13-
ValueTask InitializeAsync(Action<HciPacket> onReceived, CancellationToken cancellationToken);
13+
ValueTask InitializeAsync(
14+
Action<HciPacket> onReceived,
15+
Action<Exception> onError,
16+
CancellationToken cancellationToken
17+
);
1418
}

src/Darp.Ble.HciHost/HciHostBleObserver.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ public HciHostBleObserver(HciHostBleDevice device, ILogger<HciHostBleObserver> l
2727
{
2828
_device = device;
2929
_subscription = Host.Subscribe(this);
30+
Host.TransportFailed += OnTransportFailed;
31+
}
32+
33+
private void OnTransportFailed(object? sender, HciTransportFailedEventArgs args)
34+
{
35+
_ = OnErrorAsync(
36+
new BleObservationException(this, "The HCI transport failed while observing advertisements", args.Exception)
37+
);
3038
}
3139

3240
[MessageSink]
@@ -121,6 +129,7 @@ await Host.QueryCommandCompletionAsync<HciLeSetExtendedScanEnableCommand, HciSet
121129

122130
protected override ValueTask DisposeAsyncCore()
123131
{
132+
Host.TransportFailed -= OnTransportFailed;
124133
_subscription.Dispose();
125134
return base.DisposeAsyncCore();
126135
}

src/Darp.Ble.WinRT/WinBleObserver.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ protected override Task StartObservingAsyncCore(CancellationToken cancellationTo
5353
{
5454
if (args.Error is BluetoothError.Success)
5555
return;
56-
Logger.LogError("Watcher stopped with error {Error}", args.Error);
57-
await StopObservingAsync().ConfigureAwait(false);
56+
_observableSubscription?.Dispose();
57+
_watcher = null;
58+
await OnErrorAsync(
59+
new BleObservationException(this, $"Watcher stopped with error {args.Error}", innerException: null)
60+
)
61+
.ConfigureAwait(false);
5862
};
5963
_observableSubscription = Observable
6064
.FromEventPattern<
@@ -63,7 +67,7 @@ protected override Task StartObservingAsyncCore(CancellationToken cancellationTo
6367
BluetoothLEAdvertisementReceivedEventArgs
6468
>(addHandler => _watcher.Received += addHandler, removeHandler => _watcher.Received -= removeHandler)
6569
.Select(adv => OnAdvertisementReport(this, adv))
66-
.Subscribe(OnNext);
70+
.Subscribe(adv => OnNext(adv));
6771
return Task.CompletedTask;
6872
}
6973

src/Darp.Ble/BleObserverExtensions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ public static class BleObserverExtensions
1010
{
1111
/// <summary>
1212
/// Observes advertisements broadcast by BLE devices using the provided <see cref="IBleObserver"/>.
13+
/// Will error when a fatal error has occurred
1314
/// </summary>
1415
/// <param name="observer">The instance of <see cref="IBleObserver"/> that will monitor BLE advertisements.</param>
1516
/// <returns>An observable sequence of <see cref="IGapAdvertisement"/> instances representing BLE advertisements.</returns>
1617
public static IObservable<IGapAdvertisement> OnAdvertisement(this IBleObserver observer)
1718
{
1819
ArgumentNullException.ThrowIfNull(observer);
19-
return Observable.Create<IGapAdvertisement>(advObserver => observer.OnAdvertisement(advObserver.OnNext));
20+
return Observable.Create<IGapAdvertisement>(advObserver =>
21+
observer.OnAdvertisement(advObserver.OnNext, advObserver.OnError)
22+
);
2023
}
2124

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

2932
IObservable<IGapAdvertisement> inner = Observable.Create<IGapAdvertisement>(async observer =>
3033
{
31-
IDisposable unhook = bleObserver.OnAdvertisement(onAdvertisement: observer.OnNext);
34+
IDisposable unhook = bleObserver.OnAdvertisement(
35+
onAdvertisement: observer.OnNext,
36+
onError: observer.OnError
37+
);
3238

3339
await bleObserver.StartObservingAsync().ConfigureAwait(false);
3440

src/Darp.Ble/IBleObserver.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ public interface IBleObserver
2626

2727
/// <summary> Register a callback called when an advertisement was received </summary>
2828
/// <param name="onAdvertisement"> The callback </param>
29+
/// <param name="onError"> The callback for fatal observation errors </param>
2930
/// <returns> A disposable to unsubscribe the callback </returns>
30-
IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement);
31+
IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement, Action<Exception>? onError = null);
3132

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

src/Darp.Ble/Implementation/BleObserver.cs

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ internal enum ObserverState
2424
/// <param name="logger"> The logger </param>
2525
public abstract class BleObserver(BleDevice device, ILogger<BleObserver> logger) : IBleObserver, IAsyncDisposable
2626
{
27+
private readonly record struct AdvertisementHandlerSubscription(
28+
Action<IGapAdvertisement> OnAdvertisement,
29+
Action<Exception>? OnError
30+
);
31+
2732
private readonly BleDevice _bleDevice = device;
2833
private readonly SemaphoreSlim _startStopSemaphore = new(1, 1);
2934
private readonly Lock _handlersLock = new();
30-
private Action<IGapAdvertisement>[] _handlers = [];
35+
private AdvertisementHandlerSubscription[] _handlers = [];
3136

3237
private volatile ObserverState _observerState = ObserverState.Stopped;
3338

@@ -103,25 +108,27 @@ public async Task StartObservingAsync(CancellationToken cancellationToken = defa
103108
}
104109

105110
/// <inheritdoc />
106-
public IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement)
111+
public IDisposable OnAdvertisement(Action<IGapAdvertisement> onAdvertisement, Action<Exception>? onError = null)
107112
{
108113
ObjectDisposedException.ThrowIf(_bleDevice.IsDisposing, nameof(BleObserver));
109114

115+
var subscription = new AdvertisementHandlerSubscription(onAdvertisement, onError);
116+
110117
// Extend handlers list
111118
lock (_handlersLock)
112119
{
113-
Action<IGapAdvertisement>[] oldHandlers = _handlers;
114-
var newArr = new Action<IGapAdvertisement>[oldHandlers.Length + 1];
120+
AdvertisementHandlerSubscription[] oldHandlers = _handlers;
121+
var newArr = new AdvertisementHandlerSubscription[oldHandlers.Length + 1];
115122
Array.Copy(oldHandlers, newArr, oldHandlers.Length);
116-
newArr[^1] = onAdvertisement;
123+
newArr[^1] = subscription;
117124
Volatile.Write(ref _handlers, newArr);
118125
}
119126

120127
return Disposable.Create(
121-
(this, onAdvertisement),
128+
(this, subscription),
122129
static tuple =>
123130
{
124-
(BleObserver self, Action<IGapAdvertisement> handler) = tuple;
131+
(BleObserver self, AdvertisementHandlerSubscription handler) = tuple;
125132
lock (self._handlersLock)
126133
{
127134
if (Helpers.TryRemoveImmutable(self._handlers, handler, out var newHandlers))
@@ -143,12 +150,12 @@ protected void OnNext(IGapAdvertisement advertisement)
143150
// Taking the current snapshot of the handlers.
144151
// In case of an unsubscription of a handler we might have taken the reference here already and call it afterward.
145152
// This is a known tradeoff
146-
Action<IGapAdvertisement>[] handlers = Volatile.Read(ref _handlers);
147-
foreach (Action<IGapAdvertisement> handler in handlers)
153+
AdvertisementHandlerSubscription[] handlers = Volatile.Read(ref _handlers);
154+
foreach (AdvertisementHandlerSubscription handler in handlers)
148155
{
149156
try
150157
{
151-
handler(advertisement);
158+
handler.OnAdvertisement(advertisement);
152159
}
153160
catch (Exception e)
154161
{
@@ -158,6 +165,52 @@ protected void OnNext(IGapAdvertisement advertisement)
158165
}
159166
}
160167

168+
/// <summary> Notify subscribers of a fatal observation error. Existing subscriptions are terminated. </summary>
169+
/// <param name="exception"> The exception that triggered the error </param>
170+
protected async Task OnErrorAsync(Exception exception)
171+
{
172+
ArgumentNullException.ThrowIfNull(exception);
173+
174+
if (_bleDevice.IsDisposing)
175+
return;
176+
177+
AdvertisementHandlerSubscription[] handlers;
178+
await _startStopSemaphore.WaitAsync().ConfigureAwait(false);
179+
try
180+
{
181+
handlers = Volatile.Read(ref _handlers);
182+
if (handlers.Length == 0 && _observerState is ObserverState.Stopped)
183+
return;
184+
185+
_observerState = ObserverState.Stopped;
186+
lock (_handlersLock)
187+
{
188+
_handlers = [];
189+
}
190+
}
191+
finally
192+
{
193+
_startStopSemaphore.Release();
194+
}
195+
196+
Logger.LogObservationFailed(exception);
197+
198+
foreach (AdvertisementHandlerSubscription handler in handlers)
199+
{
200+
if (handler.OnError is null)
201+
continue;
202+
203+
try
204+
{
205+
handler.OnError(exception);
206+
}
207+
catch (Exception e)
208+
{
209+
Logger.LogObservationErrorDuringErrorHandling(e);
210+
}
211+
}
212+
}
213+
161214
/// <summary> Core implementation to start observing async </summary>
162215
/// <param name="cancellationToken"> The cancellationToken to cancel the operation </param>
163216
/// <returns> A task that completes when observation has started </returns>

0 commit comments

Comments
 (0)