From ea097b708c83211ec55d76fa6893653e9a110b3b Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 11:35:10 +0200 Subject: [PATCH 1/6] fix: Do not let consumer exceptions escape D-Bus signal handlers --- src/Linux.Bluetooth/Adapter.cs | 67 ++++++++++++++++------- src/Linux.Bluetooth/GattCharacteristic.cs | 19 +++++-- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/Linux.Bluetooth/Adapter.cs b/src/Linux.Bluetooth/Adapter.cs index 072440d..558d79e 100644 --- a/src/Linux.Bluetooth/Adapter.cs +++ b/src/Linux.Bluetooth/Adapter.cs @@ -210,21 +210,40 @@ public Task WatchPropertiesAsync(Action handler) private async void FireEventForExistingDevicesAsync() { - var devices = await this.GetDevicesAsync(); - foreach (var device in devices) + // async void: exceptions must not escape. + try + { + var devices = await this.GetDevicesAsync(); + foreach (var device in devices) + { + _deviceFound?.Invoke(this, new DeviceFoundEventArgs(device, isStateChange: false)); + } + } + catch (Exception ex) { - _deviceFound?.Invoke(this, new DeviceFoundEventArgs(device, isStateChange: false)); + Console.Error.WriteLine($"Existing-devices replay threw: {ex.Message}"); } } private async void OnDeviceAddedAsync((ObjectPath objectPath, IDictionary> interfaces) args) { - if (BlueZManager.IsMatch(BluezConstants.DeviceInterface, args.objectPath, args.interfaces, this)) + // async void: exceptions must not escape. + try { - var device = Connection.System.CreateProxy(BluezConstants.DbusService, args.objectPath); + if (BlueZManager.IsMatch(BluezConstants.DeviceInterface, args.objectPath, args.interfaces, this)) + { + var device = Connection.System.CreateProxy(BluezConstants.DbusService, args.objectPath); - var dev = await Device.CreateAsync(device); - _deviceFound?.Invoke(this, new DeviceFoundEventArgs(dev)); + var dev = await Device.CreateAsync(device); + _deviceFound?.Invoke(this, new DeviceFoundEventArgs(dev)); + + // Relay this device's connection state if anyone is listening to the adapter-level events. + TrackDeviceForConnection(args.objectPath); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"InterfacesAdded handler threw: {ex.Message}"); } } @@ -247,23 +266,31 @@ private async void FireEventIfPropertyAlreadyTrueAsync(AdapterEventHandlerAsync private void OnPropertyChanges(PropertyChanges changes) { - foreach (var pair in changes.Changed) + // Runs on the DBus receive loop: consumer throws must not escape. + try { - switch (pair.Key) + foreach (var pair in changes.Changed) { - case "Powered": - if (true.Equals(pair.Value)) - { - _poweredOn?.Invoke(this, new BlueZEventArgs()); - } - else - { - PoweredOff?.Invoke(this, new BlueZEventArgs()); - } - - break; + switch (pair.Key) + { + case "Powered": + if (true.Equals(pair.Value)) + { + _poweredOn?.Invoke(this, new BlueZEventArgs()); + } + else + { + PoweredOff?.Invoke(this, new BlueZEventArgs()); + } + + break; + } } } + catch (Exception ex) + { + Console.Error.WriteLine($"Adapter property handler threw: {ex.Message}"); + } } } } diff --git a/src/Linux.Bluetooth/GattCharacteristic.cs b/src/Linux.Bluetooth/GattCharacteristic.cs index c633e01..01fce21 100644 --- a/src/Linux.Bluetooth/GattCharacteristic.cs +++ b/src/Linux.Bluetooth/GattCharacteristic.cs @@ -131,16 +131,23 @@ private async void Subscribe() private void OnPropertyChanges(PropertyChanges changes) { - // Console.WriteLine("OnPropertyChanges called."); - foreach (var pair in changes.Changed) + // Runs on the DBus receive loop: consumer throws must not escape. + try { - switch (pair.Key) + foreach (var pair in changes.Changed) { - case "Value": - _onValue?.Invoke(this, new GattCharacteristicValueEventArgs((byte[])pair.Value)); - break; + switch (pair.Key) + { + case "Value": + _onValue?.Invoke(this, new GattCharacteristicValueEventArgs((byte[])pair.Value)); + break; + } } } + catch (Exception ex) + { + Console.Error.WriteLine($"Characteristic value handler threw: {ex.Message}"); + } } } } From c475e67e2e4bd7398969f25e364f4e203101e469 Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 13:23:41 +0200 Subject: [PATCH 2/6] fix(GattServer): Do not dispose the server from its finalizer --- src/Linux.Bluetooth/GattServer/GattServer.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Linux.Bluetooth/GattServer/GattServer.cs b/src/Linux.Bluetooth/GattServer/GattServer.cs index 05354d5..0e0b21e 100644 --- a/src/Linux.Bluetooth/GattServer/GattServer.cs +++ b/src/Linux.Bluetooth/GattServer/GattServer.cs @@ -28,11 +28,6 @@ public GattServer(Adapter adapter) _gattManager = Connection.CreateProxy(BluezConstants.DbusService, adapter.ObjectPath); } - ~GattServer() - { - Dispose(); - } - public void Dispose() { Task.Run(async () => @@ -43,7 +38,6 @@ public void Dispose() Console.Error.WriteLine("Disposed Gatt server."); Connection.Dispose(); - GC.SuppressFinalize(this); } public async Task InitializeAsync() From 4f7265a482baf405fe4dd75d584b3f59f4ad44fe Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 17:47:23 +0200 Subject: [PATCH 3/6] fix(GattServer): Treat an already-gone object as an unregister success BlueZ answers org.bluez.Error.DoesNotExist when the advertisement or the application it is asked to unregister is already gone, which a bluetoothd or DBus restart makes routine. Unregistering has then reached its goal, so the error is swallowed instead of failing the teardown and, through Dispose(), the caller that only wanted the server gone. --- src/Linux.Bluetooth/GattServer/GattServer.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Linux.Bluetooth/GattServer/GattServer.cs b/src/Linux.Bluetooth/GattServer/GattServer.cs index 0e0b21e..0e04078 100644 --- a/src/Linux.Bluetooth/GattServer/GattServer.cs +++ b/src/Linux.Bluetooth/GattServer/GattServer.cs @@ -102,7 +102,15 @@ public async Task UnregisterGattApplication() if (_gattApplication is not null) { // unregister the application before unregister objects - await _gattManager.UnregisterApplicationAsync(_gattApplication.ObjectPath); + try + { + await _gattManager.UnregisterApplicationAsync(_gattApplication.ObjectPath); + } + catch (DBusException ex) when (ex.ErrorName == "org.bluez.Error.DoesNotExist") + { + // BlueZ already dropped it (e.g. bluetoothd restarted): that is the wanted end state. + } + foreach (GattService service in _gattApplication.Services) { @@ -143,7 +151,15 @@ public async Task UnregisterAdvertisement() { if (_advertisement is not null) { - await _advManager.UnregisterAdvertisementAsync(_advertisement.ObjectPath); + try + { + await _advManager.UnregisterAdvertisementAsync(_advertisement.ObjectPath); + } + catch (DBusException ex) when (ex.ErrorName == "org.bluez.Error.DoesNotExist") + { + // BlueZ already dropped it (e.g. bluetoothd restarted): that is the wanted end state. + } + Connection.UnregisterObject(_advertisement); Debug.WriteLine($"Advertisement {_advertisement.ObjectPath} unregistered"); _advertisement = null; From 16dbd15c2ffd328d0c7b174fd0ea8299e79a77de Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 17:47:31 +0200 Subject: [PATCH 4/6] fix(GattServer): Trace a successful disposal instead of logging an error Dispose() reported its own success on stderr, so every teardown surfaced as an error line in the consumer's log. Debug.WriteLine matches the other traces of the class and compiles out of release builds. --- src/Linux.Bluetooth/GattServer/GattServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Linux.Bluetooth/GattServer/GattServer.cs b/src/Linux.Bluetooth/GattServer/GattServer.cs index 0e04078..6d74a18 100644 --- a/src/Linux.Bluetooth/GattServer/GattServer.cs +++ b/src/Linux.Bluetooth/GattServer/GattServer.cs @@ -36,7 +36,7 @@ public void Dispose() await UnregisterGattApplication(); }).Wait(); - Console.Error.WriteLine("Disposed Gatt server."); + Debug.WriteLine("Disposed Gatt server."); Connection.Dispose(); } From 31ce33c160e2ff7a3839dfaca05c99587c7bd635 Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 17:47:45 +0200 Subject: [PATCH 5/6] fix(Agent): Do not unregister the DBus object from a finalizer ~Agent() ran Dispose(), which issues an UnregisterObject call on a connection the agent does not own and may already have been disposed by its owner. ~AgentManager() called a Dispose() whose only statement was SuppressFinalize. Both objects are IDisposable, so their cleanup belongs to their owner. --- src/Linux.Bluetooth/Agent.cs | 6 ------ src/Linux.Bluetooth/AgentManager.cs | 6 ------ 2 files changed, 12 deletions(-) diff --git a/src/Linux.Bluetooth/Agent.cs b/src/Linux.Bluetooth/Agent.cs index 0560779..de29f70 100644 --- a/src/Linux.Bluetooth/Agent.cs +++ b/src/Linux.Bluetooth/Agent.cs @@ -62,11 +62,6 @@ public class Agent : IAgent1, IDisposable private event AgentDisplayPasskeyEventHandlerAsync? _passkeyDisplayed; private event AgentOperationCancelledEventHandlerAsync? _operationCancelled; - ~Agent() - { - Dispose(); - } - private Agent(Connection connection, string capability = DefaultCapability, ObjectPath? objectPath = null) { _connection = connection; @@ -99,7 +94,6 @@ internal static async Task CreateAsync(Connection connection, string capa public void Dispose() { UnregisterObject(); - GC.SuppressFinalize(this); } /// diff --git a/src/Linux.Bluetooth/AgentManager.cs b/src/Linux.Bluetooth/AgentManager.cs index f29741e..f2841b6 100644 --- a/src/Linux.Bluetooth/AgentManager.cs +++ b/src/Linux.Bluetooth/AgentManager.cs @@ -14,11 +14,6 @@ public class AgentManager : IAgentManager1, IDisposable { private readonly IAgentManager1 _proxy; - ~AgentManager() - { - Dispose(); - } - private AgentManager(IAgentManager1 proxy) { _proxy = proxy; @@ -47,7 +42,6 @@ internal static Task CreateAsync(Connection connection) /// public void Dispose() { - GC.SuppressFinalize(this); } /// From 51e745d2d1ad51db001d44e61291503d7e347a44 Mon Sep 17 00:00:00 2001 From: Valentin Kindschi Date: Thu, 6 Aug 2026 17:57:11 +0200 Subject: [PATCH 6/6] fix: Do not dispose signal watchers from finalizers Adapter and Device finalizers called the public Dispose, so the finalizer thread disposed managed watchers and issued DBus traffic on the shared system connection. Removed both finalizers and the now-dead GC.SuppressFinalize calls. Callers that never dispose these objects now leak a signal-handler delegate instead of having it released non-deterministically. Added BlueZManager.GetAdapterProxiesAsync so a caller can select an adapter without GetAdaptersAsync registering three watchers for every adapter on the bus, including the ones it discards. Adapter.CreateAsync is now public so an external assembly can build the adapter it keeps. GetAdaptersAsync is unchanged. --- src/Linux.Bluetooth/Adapter.cs | 9 +-------- src/Linux.Bluetooth/BlueZManager.cs | 8 ++++++++ src/Linux.Bluetooth/Device.cs | 7 ------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Linux.Bluetooth/Adapter.cs b/src/Linux.Bluetooth/Adapter.cs index 558d79e..84a0fde 100644 --- a/src/Linux.Bluetooth/Adapter.cs +++ b/src/Linux.Bluetooth/Adapter.cs @@ -28,12 +28,7 @@ public class Adapter : IAdapter1, IDisposable private IObjectManager ObjectManager => _objectManager ?? throw new InvalidOperationException("Adapter object manager has not been initialized."); - ~Adapter() - { - Dispose(); - } - - internal static async Task CreateAsync(IAdapter1 proxy) + public static async Task CreateAsync(IAdapter1 proxy) { var adapter = new Adapter { @@ -54,8 +49,6 @@ public void Dispose() _interfacesWatcher = null; _propertyWatcher?.Dispose(); _propertyWatcher = null; - - GC.SuppressFinalize(this); } public event DeviceChangeEventHandlerAsync DeviceFound diff --git a/src/Linux.Bluetooth/BlueZManager.cs b/src/Linux.Bluetooth/BlueZManager.cs index 11ab1ba..73dcd6f 100644 --- a/src/Linux.Bluetooth/BlueZManager.cs +++ b/src/Linux.Bluetooth/BlueZManager.cs @@ -47,6 +47,14 @@ public static async Task> GetAdaptersAsync() return await Task.WhenAll(adapters.Select(Adapter.CreateAsync)); } + /// Get the adapter proxies without creating signal watchers. + /// Select a proxy, then call on it. + /// The adapter proxies. + public static Task> GetAdapterProxiesAsync() + { + return GetProxiesAsync(BluezConstants.AdapterInterface, rootObject: null); + } + // Normalize a 16, 32 or 128 bit UUID. public static string NormalizeUUID(string uuid) { diff --git a/src/Linux.Bluetooth/Device.cs b/src/Linux.Bluetooth/Device.cs index 64298c9..af71102 100644 --- a/src/Linux.Bluetooth/Device.cs +++ b/src/Linux.Bluetooth/Device.cs @@ -26,11 +26,6 @@ public class Device : IDevice1, IDisposable private IDevice1 Proxy => _proxy ?? throw new InvalidOperationException("Device has not been initialized."); - ~Device() - { - Dispose(); - } - internal static async Task CreateAsync(IDevice1 proxy) { var device = new Device @@ -47,8 +42,6 @@ public void Dispose() { _propertyWatcher?.Dispose(); _propertyWatcher = null; - - GC.SuppressFinalize(this); } public event DeviceEventHandlerAsync Connected