Skip to content

fix(linux): actually unsubscribe BlueZ property watchers - #752

Open
AJHH24 wants to merge 1 commit into
timschneeb:masterfrom
AJHH24:fix/linux-bluez-watcher-leak
Open

AJHH24 wants to merge 1 commit into
timschneeb:masterfrom
AJHH24:fix/linux-bluez-watcher-leak

Conversation

@AJHH24

@AJHH24 AJHH24 commented Oct 11, 2026

Copy link
Copy Markdown

Summary

On Linux the app often fails to reconnect after the earbuds go back in the case and come out again: BlueZ shows them connected and audio plays, but the app stays disconnected until it is restarted. The log shows the auto-reconnect never runs, only:

Exception: System.InvalidOperationException: An attempt was made to transition a task to a final state when it had already completed.
   at System.Threading.Tasks.TaskCompletionSource`1.SetResult(TResult result)
   at ThePBone.BlueZNet.Extensions.<>c__DisplayClass5_0`2.<WatchPropertyValueAsync>b__0(PropertyChanges propertyChanges)

Cause

WatchPropertiesAsync returns Task<IDisposable>. Because Task is itself IDisposable, the ret is IDisposable check in WatchForPropertyChangeAsync and WatchPropertyValueAsync stores the task as the watcher, so Dispose() disposes the task and never removes the D-Bus match. As a result:

  • every connect leaks a Connected watchdog, so each disconnect is reported once per earlier connect (the log shows Linux.BluetoothService: Disconnected twice after two connects);
  • every WaitForPropertyValueAsync leaks a watcher whose TaskCompletionSource already completed. On the next reconnect it calls SetResult again, the exception escapes the signal handler (the catch calls SetException, which throws too), and the handler that would have triggered ConnectionWatcherCallback never runs.

Change

  • Wrap the returned Task<IDisposable> so disposing the watcher disposes the real subscription once it is available.
  • Use TrySetResult/TrySetException so a repeated property change cannot throw.
  • Dispose the watcher on the timeout path as well.

Testing

Linux (Arch, BlueZ), Galaxy Buds4 Pro. Disconnect/reconnect twice in a row with bluetoothctl, then the real case: buds in the closed case until they drop, then out. Each disconnect is now logged once, Reconnected. Attempting to auto-connect to profile... follows every reconnect, and no exception is thrown.

WatchPropertiesAsync returns Task<IDisposable>. Since Task is itself
IDisposable, the `ret is IDisposable` check in the BlueZNet helpers stored
the task as the watcher, so Dispose() disposed the task and never removed
the D-Bus match. Every connect leaked a "Connected" watchdog (each
disconnect was then reported once per earlier connect) and a
WaitForPropertyValueAsync watcher whose TaskCompletionSource had already
completed. On the next reconnect that stale watcher called SetResult
again, threw out of the signal handler, and the auto-reconnect watchdog
never ran: the earbuds came back in BlueZ but the app stayed disconnected
until restarted.

Dispose the real subscription once it is available, use TrySetResult /
TrySetException so a repeated change cannot throw, and dispose the watcher
on timeout as well.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant