From 38189e9bfda18d7c43b4463076ad81318b2f3f42 Mon Sep 17 00:00:00 2001 From: Justin Pfister <119864532+JJRPF@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:24:29 -0400 Subject: [PATCH] fix(bluetooth): power off adapters via BlueZ before rfkill block On Apple Silicon Macs (BCM4377/BCM4388 driven by hci_bcm4377), calling rfkill block bluetooth directly while discovery or connection is active causes HCI command timeouts (-110) followed by 10 transfer and completion ring destruction timeouts. The controller is left in an unrecoverable state where bluetoothctl power on fails until an unbind/bind or reboot. Gracefully powering off controllers through BlueZ first stops discovery and drains DMA queues cleanly, preventing driver timeouts while preserving the rfkill soft block for reboot persistence. Enforce a bounded completion barrier via wait_unpowered to ensure all controllers (including secondary controllers submitted asynchronously) reach Powered: no before applying the rfkill block. If any controller fails to power down before the deadline, log a warning to stderr, apply the rfkill block as a safety fallback, and return non-zero status. Upstream kernel report: https://github.com/AsahiLinux/linux/issues/609 --- bin/omarchy-bluetooth-power | 45 ++++++++++++-- test/shell.d/bluetooth-test.sh | 110 ++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/bin/omarchy-bluetooth-power b/bin/omarchy-bluetooth-power index 7f30533beba..f8691dc1323 100755 --- a/bin/omarchy-bluetooth-power +++ b/bin/omarchy-bluetooth-power @@ -48,6 +48,16 @@ wait_powered() { done } +wait_unpowered() { + local deadline=$((SECONDS + POWER_WAIT_SECONDS)) + + while :; do + powered || return 0 + ((SECONDS < deadline)) || return 1 + sleep 0.1 + done +} + power_on() { rfkill unblock bluetooth @@ -63,18 +73,45 @@ power_on() { return 1 } +power_off() { + local controller + local status=0 + + # Gracefully power down adapters through BlueZ before cutting radio power. + # This stops active discovery inquiry scans and drains DMA queues, preventing + # HCI command timeouts and corrupted transfer rings on PCIe Bluetooth chips + # (such as Broadcom/Apple Silicon hci_bcm4377). Piped interactive commands + # exit on stdin EOF before BlueZ finishes powering down secondary adapters, so + # wait until all controllers report unpowered before dropping the rfkill block. + for controller in $(controllers); do + printf "select %s\npower off\n" "$controller" | timeout 2s bluetoothctl >/dev/null 2>&1 + done + + timeout 2s bluetoothctl power off >/dev/null 2>&1 + + if ! wait_unpowered; then + status=1 + for controller in $(controllers); do + if [[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]]; then + echo "omarchy-bluetooth-power: warning: controller $controller did not power down cleanly" >&2 + fi + done + fi + + rfkill block bluetooth || return $? + return "$status" +} + case "${1:-}" in on) power_on ;; off) - # No bluetoothctl power off to go with this: the block already drops the - # adapter to Powered: no, and it is the half that survives the reboot. - rfkill block bluetooth + power_off ;; toggle) if powered; then - rfkill block bluetooth + power_off else power_on fi diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh index b20727e6d9c..30e20d70d9a 100644 --- a/test/shell.d/bluetooth-test.sh +++ b/test/shell.d/bluetooth-test.sh @@ -155,14 +155,58 @@ export POWERED_FILE="$device_tmp/powered" cat >"$mock_bin/bluetoothctl" <<'SH' #!/bin/bash +if [[ $# -eq 0 ]]; then + selected="" + while IFS= read -r line; do + printf '%s\n' "$line" >>"$BLUETOOTHCTL_LOG" + cmd=($line) + case "${cmd[0]:-}" in + select) + selected="${cmd[1]:-}" + ;; + power) + case "${cmd[1]:-}" in + on) + target="$POWERED_FILE" + [[ -n $selected && -f "$POWERED_FILE.$selected" ]] && target="$POWERED_FILE.$selected" + echo yes >"$target" + ;; + off) + if [[ -f "$POWERED_FILE.stuck" || (-n $selected && -f "$POWERED_FILE.stuck.$selected") ]]; then + : + elif [[ -n $selected && -f "$POWERED_FILE.$selected" ]]; then + echo no >"$POWERED_FILE.$selected" + else + echo no >"$POWERED_FILE" + fi + ;; + esac + ;; + esac + done + exit 0 +fi + printf '%s\n' "$*" >>"$BLUETOOTHCTL_LOG" [[ $1 == "power" && $2 == "on" ]] && echo yes >"$POWERED_FILE" +[[ $1 == "power" && $2 == "off" && ! -f "$POWERED_FILE.stuck" ]] && echo no >"$POWERED_FILE" [[ $1 == "list" ]] && for c in ${MOCK_CONTROLLERS:-AA:BB:CC:DD:EE:FF}; do printf 'Controller %s mock\n' "$c"; done + # Per-controller state where a test set it, the shared file otherwise. if [[ $1 == "show" ]]; then + controller="${2:-}" + delay_file="$POWERED_FILE.delay.$controller" + if [[ -n $controller && -f $delay_file ]]; then + probes=$(cat "$delay_file") + if (( probes > 0 )); then + echo $(( probes - 1 )) >"$delay_file" + printf '\tPowered: yes\n' + exit 0 + fi + fi state="$POWERED_FILE" - [[ -n ${2:-} && -f "$POWERED_FILE.$2" ]] && state="$POWERED_FILE.$2" + [[ -n $controller && -f "$POWERED_FILE.$controller" ]] && state="$POWERED_FILE.$controller" printf '\tPowered: %s\n' "$(cat "$state")" fi exit 0 @@ -190,7 +234,7 @@ bluetooth_run() { echo "$powered" >"$POWERED_FILE" : >"$device_tmp/log" PATH="$mock_bin:$ROOT/bin:$PATH" BLUETOOTHCTL_LOG="$device_tmp/log" \ - OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=0 "$@" || + OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=${MOCK_WAIT_SECONDS:-0} "$@" || fail "$* exits cleanly with Powered: $powered" printf '%s' "$device_tmp/log" } @@ -199,16 +243,17 @@ bluetooth_power() { bluetooth_run "$1" "$ROOT/bin/omarchy-bluetooth-power" "$2" } -# Off has to be the block. A bluetoothctl power off would read the same until the -# next boot, then quietly come back on. +# Turning off powers adapters down through BlueZ first to drain DMA queues +# (preventing hci_bcm4377 ring destruction hangs), followed by an rfkill block +# so the state persists across reboots. off_log=$(bluetooth_power yes off) grep -qx "rfkill block bluetooth" "$off_log" || fail "bluetooth turns off with an rfkill block" "$(cat "$off_log")" pass "bluetooth turns off with an rfkill block" -grep -q "power off" "$off_log" && - fail "bluetooth does not also power the adapter down" "$(cat "$off_log")" -pass "bluetooth does not also power the adapter down" +grep -q "power off" "$off_log" || + fail "bluetooth powers adapters down before the rfkill block" "$(cat "$off_log")" +pass "bluetooth powers adapters down before the rfkill block" # Unblocking is enough on its own, so there is nothing left to ask bluetoothctl. on_log=$(bluetooth_power no on) @@ -275,6 +320,57 @@ grep -qx "rfkill block bluetooth" "$multi_log" || fail "bluetooth counts a secondary controller as on" "$(cat "$multi_log")" pass "bluetooth counts a secondary controller as on" +# Graceful power-off acts as a completion barrier for secondary controllers: +# omarchy-bluetooth-power polls and waits for a delayed secondary controller to +# reach Powered: no before cutting power with an rfkill block. +echo yes >"$POWERED_FILE" +echo yes >"$POWERED_FILE.11:22:33:44:55:66" +echo 2 >"$POWERED_FILE.delay.11:22:33:44:55:66" +export MOCK_CONTROLLERS="AA:BB:CC:DD:EE:FF 11:22:33:44:55:66" +delayed_log=$(MOCK_WAIT_SECONDS=1 bluetooth_power yes off) +unset MOCK_CONTROLLERS +rm -f "$POWERED_FILE.11:22:33:44:55:66" "$POWERED_FILE.delay.11:22:33:44:55:66" + +grep -qx "rfkill block bluetooth" "$delayed_log" || + fail "bluetooth blocks rfkill after delayed secondary controller settles" "$(cat "$delayed_log")" +pass "bluetooth blocks rfkill after delayed secondary controller settles" + +awk ' + /show 11:22:33:44:55:66/ { shows++ } + /rfkill block bluetooth/ { blocks++; shows_before_block = shows } + END { + if (shows_before_block < 2 || blocks == 0) exit 1 + } +' "$delayed_log" || + fail "bluetooth waits for delayed secondary controller before rfkill block" "$(cat "$delayed_log")" +pass "bluetooth waits for delayed secondary controller before rfkill block" + +# Power-off timeout on a stuck controller emits a warning to stderr and still +# executes the rfkill block as a safe fallback. +echo yes >"$POWERED_FILE" +echo yes >"$POWERED_FILE.11:22:33:44:55:66" +touch "$POWERED_FILE.stuck.11:22:33:44:55:66" +export MOCK_CONTROLLERS="AA:BB:CC:DD:EE:FF 11:22:33:44:55:66" +: >"$device_tmp/log" +timeout_err="$device_tmp/timeout_err" +timeout_status=0 +PATH="$mock_bin:$ROOT/bin:$PATH" BLUETOOTHCTL_LOG="$device_tmp/log" \ + OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=0 "$ROOT/bin/omarchy-bluetooth-power" off 2>"$timeout_err" || timeout_status=$? +unset MOCK_CONTROLLERS +rm -f "$POWERED_FILE.11:22:33:44:55:66" "$POWERED_FILE.stuck.11:22:33:44:55:66" + +[[ $timeout_status -ne 0 ]] || + fail "bluetooth power off exits non-zero when controller remains powered" +pass "bluetooth power off exits non-zero when controller remains powered" + +grep -q "controller 11:22:33:44:55:66 did not power down cleanly" "$timeout_err" || + fail "bluetooth power off warns on stuck controller" "$(cat "$timeout_err")" +pass "bluetooth power off warns on stuck controller" + +grep -qx "rfkill block bluetooth" "$device_tmp/log" || + fail "bluetooth still blocks rfkill when power-off times out" "$(cat "$device_tmp/log")" +pass "bluetooth still blocks rfkill when power-off times out" + # AutoEnable=false was the old attempt at persistence and never worked. Left set, # it would also keep bluetoothd from powering the adapter up after an unblock. grep -q 'AutoEnable=false' "$ROOT/install/hardware/bluetooth.sh" &&