From b9a5b33e78f8f04dd9f6bf322aed3c5a42b51e68 Mon Sep 17 00:00:00 2001 From: n0mahd Date: Thu, 17 Sep 2026 14:54:41 -0400 Subject: [PATCH] Recover Bluetooth after resume on Apple Silicon Macs The Broadcom Bluetooth firmware wedges across s2idle: on resume the controller stops answering HCI commands, every opcode fails with -110, and neither the bar toggle nor bluetoothctl can power it back on. Only a driver rebind clears it (#338, upstream AsahiLinux/linux#604). Recover with omarchy-bluetooth-resume-fix from a service ordered after the sleep targets, mirroring how fix-wifi-resume.sh recovers the Wi-Fi half of the same chip. The command rebinds only once the tx timeout signature appears since the last suspend entry, so it stays a no-op on machines and kernels where the firmware bug does not bite. It reads from the entry marker rather than "PM: suspend exit" because the kernel logs the exit marker after device resume, behind the timeouts. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-bluetooth-resume-fix | 159 +++++++++++ install/hardware/all.sh | 1 + .../hardware/apple/fix-bluetooth-resume.sh | 42 +++ migrations/1789670852.sh | 20 ++ test/shell.d/bluetooth-resume-fix-test.sh | 267 ++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100755 bin/omarchy-bluetooth-resume-fix create mode 100644 install/hardware/apple/fix-bluetooth-resume.sh create mode 100644 migrations/1789670852.sh create mode 100755 test/shell.d/bluetooth-resume-fix-test.sh diff --git a/bin/omarchy-bluetooth-resume-fix b/bin/omarchy-bluetooth-resume-fix new file mode 100755 index 00000000000..a07b7354bf4 --- /dev/null +++ b/bin/omarchy-bluetooth-resume-fix @@ -0,0 +1,159 @@ +#!/bin/bash + +# omarchy:summary=Rebind hci_bcm4377 if Bluetooth wedges across resume +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# The Broadcom Bluetooth firmware on Apple Silicon Macs can wedge across +# s2idle: the controller stops answering on resume, so every HCI opcode fails +# with -110 and the kernel logs "command 0x0c01 tx timeout" every couple of +# seconds. BlueZ cannot clear it - both the bar toggle and bluetoothctl power +# on fail - and only unbinding and rebinding the PCIe driver brings it back. +# Upstream: https://github.com/AsahiLinux/linux/issues/604 +# +# Run by omarchy-bluetooth-resume-fix.service after each resume (installed by +# install/hardware/apple/fix-bluetooth-resume.sh). Output goes to the journal: +# journalctl -u omarchy-bluetooth-resume-fix +# +# Unlike Wi-Fi, an idle Bluetooth controller has no "should be connected by +# now" state to time out on: a powered adapter with nothing in range is +# healthy. So this acts on the tx timeout signature alone and never on a +# backstop timer, which keeps it from rebinding hardware that is fine. + +WAIT_BEFORE=20 # how long to watch for the wedge signature after resume +WAIT_AFTER=15 # how long to wait for the controller to return after a rebind + +# The shell tests point these at a sandbox; nothing else overrides them. +DRIVER=${OMARCHY_BLUETOOTH_DRIVER_DIR:-/sys/bus/pci/drivers/hci_bcm4377} +CONTROLLERS=${OMARCHY_BLUETOOTH_CLASS_DIR:-/sys/class/bluetooth} + +# A wedged controller repeats this every couple of seconds and a healthy one +# never logs it at all. HCI_OP_RESET (0x0c01) is the usual first casualty, but +# any opcode timing out is the same hang, so match them all. +WEDGE_SIGNATURE='hci[0-9]+: command 0x[0-9a-f]+ tx timeout' + +START=$(date '+%Y-%m-%d %H:%M:%S') + +# The kernel holds these messages back while the console is suspended and +# flushes them at resume, which can land them in the journal before this +# service even starts, so a cursor taken now would miss them. Anchor on the +# suspend entry instead: nothing is logged while the machine is asleep, so +# everything after it is the suspend and resume path. Not the exit marker - +# the kernel logs "PM: suspend exit" last, after device resume, and the tx +# timeouts land ahead of it. The cursor also survives the clock stepping +# backwards across resume, which a --since window does not. +suspend_cursor() { + journalctl -kq -n 1 -g 'PM: suspend entry' --show-cursor 2>/dev/null | + sed -n 's/^-- cursor: //p' +} + +wedged() { + local n + + if [[ -n $CURSOR ]]; then + n=$(journalctl -kq --after-cursor "$CURSOR" --no-pager 2>/dev/null | + grep -cE "$WEDGE_SIGNATURE") + else + # No suspend marker to read from, so a --since window is the best left. It + # comes back empty if the clock stepped backwards across resume, and the + # next resume gets another chance - better than an unwindowed read, which + # would count stale timeouts from a wedge that was already recovered. + n=$(journalctl -kq --since "$START" --no-pager 2>/dev/null | + grep -cE "$WEDGE_SIGNATURE") + fi + + ((${n:-0} > 0)) +} + +# Whatever PCI function is bound to the driver, rather than a hard-coded +# address: the Wi-Fi and Bluetooth halves of these chips sit on the same +# device, and only the Bluetooth function is ever bound here. +bound_controller() { + local path + + for path in "$DRIVER"/*:*:*.*; do + if [[ -e $path ]]; then + basename "$path" + return 0 + fi + done + + return 1 +} + +controller_back() { + compgen -G "$CONTROLLERS/hci*" >/dev/null +} + +for cmd in journalctl rfkill; do + if omarchy-cmd-missing "$cmd"; then + echo "missing $cmd - cannot recover Bluetooth" + exit 1 + fi +done + +# Respect a deliberately disabled radio - do not fight the user or rfkill. +# omarchy-bluetooth-power keeps the off state as an rfkill soft block, so a +# blocked radio is a choice rather than a wedge. +soft_state=$(rfkill --noheadings --output SOFT list bluetooth 2>/dev/null) +if [[ -z $soft_state ]]; then + echo "no bluetooth radio present, nothing to do" + exit 0 +fi +if ! grep -q "unblocked" <<<"$soft_state"; then + echo "bluetooth is soft blocked, nothing to do" + exit 0 +fi + +DEVICE=$(bound_controller) +if [[ -z $DEVICE ]]; then + echo "no controller bound to hci_bcm4377, nothing to rebind" + exit 0 +fi + +CURSOR=$(suspend_cursor) +if [[ -z $CURSOR ]]; then + echo "no suspend marker in the journal - watching from now instead" +fi + +i=0 +while ((i < WAIT_BEFORE)); do + if wedged; then + echo "wedged controller confirmed ${i}s after resume ($DEVICE) - rebinding hci_bcm4377" + break + fi + i=$((i + 1)) + sleep 1 +done + +if ((i >= WAIT_BEFORE)); then + echo "no wedge signature ${WAIT_BEFORE}s after resume - bluetooth came back on its own" + exit 0 +fi + +# Unbinding a hung controller is slow: the driver destroys six transfer rings +# and four completion rings, and every one of them waits out its own timeout, +# so this write can sit for ten seconds before it returns. +if ! echo "$DEVICE" >"$DRIVER/unbind" 2>/dev/null; then + echo "failed to unbind $DEVICE - giving up, a reboot is needed" + exit 1 +fi +sleep 1 +if ! echo "$DEVICE" >"$DRIVER/bind" 2>/dev/null; then + echo "failed to bind $DEVICE - giving up, a reboot is needed" + exit 1 +fi +echo "rebound $DEVICE, waiting for the controller to come back" + +i=0 +while ((i < WAIT_AFTER)); do + if controller_back; then + echo "controller back ${i}s after the rebind" + exit 0 + fi + i=$((i + 1)) + sleep 1 +done + +echo "controller still missing ${WAIT_AFTER}s after the rebind" +exit 1 diff --git a/install/hardware/all.sh b/install/hardware/all.sh index ca12c782e1c..152580caa4b 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -41,6 +41,7 @@ run_logged "$OMARCHY_INSTALL/hardware/apple/fix-suspend-nvme.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/fix-t2.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/fix-brcmfmac-supplicant.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/fix-wifi-resume.sh" +run_logged "$OMARCHY_INSTALL/hardware/apple/fix-bluetooth-resume.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/fix-asahi-hid-race.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/enable-notch.sh" run_logged "$OMARCHY_INSTALL/hardware/apple/electron-gl.sh" diff --git a/install/hardware/apple/fix-bluetooth-resume.sh b/install/hardware/apple/fix-bluetooth-resume.sh new file mode 100644 index 00000000000..b7e212c0764 --- /dev/null +++ b/install/hardware/apple/fix-bluetooth-resume.sh @@ -0,0 +1,42 @@ +# The Broadcom Bluetooth firmware on Apple Silicon Macs wedges across s2idle: +# on resume the controller stops answering HCI commands, every opcode fails +# with -110, and neither the bar toggle nor bluetoothctl can power it back on. +# Only unbinding and rebinding the PCIe driver clears it. +# Upstream: https://github.com/AsahiLinux/linux/issues/604 +# +# Recover with omarchy-bluetooth-resume-fix from a service ordered after +# suspend.target, so it runs on resume without delaying it - the same shape +# fix-wifi-resume.sh uses for the Wi-Fi half of the same chip. The command +# rebinds only once the tx timeout signature appears, so it stays a no-op on +# machines and kernels where the firmware bug does not bite, and stops acting +# by itself if the bug is ever fixed. +# +# BCM4378 (14e4:5f69) is verified: an M1 MacBook Air wedged on resume, and the +# rebind recovered the controller without a reboot. BCM4387 (14e4:5f71) is the +# same failure reported on M2 Airs in #338 and #302. +# +# BCM4388 (14e4:5f72) is deliberately absent, not an oversight: the only +# report on that part is the rfkill path fixed in #380 (upstream +# AsahiLinux/linux#609), with nothing showing it wedging across resume on its +# own. + +if [[ $(uname -m) == "aarch64" ]] && lspci -nn | grep -E "14e4:(5f69|5f71)" >/dev/null; then + echo "Detected Apple Silicon Broadcom Bluetooth; installing resume recovery" + + cat > /etc/systemd/system/omarchy-bluetooth-resume-fix.service <<'EOF' +[Unit] +Description=Rebind hci_bcm4377 if Bluetooth wedges across resume +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +After=bluetooth.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-bluetooth-resume-fix +TimeoutStartSec=120 + +[Install] +WantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +EOF + + systemctl enable omarchy-bluetooth-resume-fix.service +fi diff --git a/migrations/1789670852.sh b/migrations/1789670852.sh new file mode 100644 index 00000000000..0876bcafd6a --- /dev/null +++ b/migrations/1789670852.sh @@ -0,0 +1,20 @@ +echo "Recover Bluetooth after resume on Apple Silicon Macs" + +# The Broadcom Bluetooth firmware on Apple Silicon Macs (BCM4378/BCM4387) can +# wedge across s2idle, leaving the controller deaf to every HCI command until +# the driver is rebound (#338, upstream AsahiLinux/linux#604). Fresh installs +# get the recovery service from the hardware leaf; run the same leaf here for +# existing installs. + +[[ $(uname -m) == "aarch64" ]] || exit 0 +# grep without -q reads all of lspci's output: this runs under pipefail, where +# an early -q exit would kill a chatty lspci with SIGPIPE and read the failed +# pipeline as "no such hardware" (#6608). +lspci -nn | grep -E "14e4:(5f69|5f71)" >/dev/null || exit 0 + +# Another user on this machine may already have applied the repair. +if systemctl is-enabled --quiet omarchy-bluetooth-resume-fix.service 2>/dev/null; then + exit 0 +fi + +sudo bash "$OMARCHY_PATH/install/hardware/apple/fix-bluetooth-resume.sh" diff --git a/test/shell.d/bluetooth-resume-fix-test.sh b/test/shell.d/bluetooth-resume-fix-test.sh new file mode 100755 index 00000000000..101edebf572 --- /dev/null +++ b/test/shell.d/bluetooth-resume-fix-test.sh @@ -0,0 +1,267 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leaf="$ROOT/install/hardware/apple/fix-bluetooth-resume.sh" +all="$ROOT/install/hardware/all.sh" +fix="$ROOT/bin/omarchy-bluetooth-resume-fix" + +[[ -f $leaf ]] || fail "the Bluetooth resume recovery leaf ships" +grep -Fq 'apple/fix-bluetooth-resume.sh' "$all" || + fail "Bluetooth resume recovery runs during hardware setup" +migration=$(grep -rlF 'fix-bluetooth-resume.sh' "$ROOT/migrations" 2>/dev/null | head -1) +[[ -n $migration ]] || fail "existing installs get the Bluetooth resume recovery" +pass "fresh and existing installs are wired to Bluetooth resume recovery" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +service="$test_tmp/etc/systemd/system/omarchy-bluetooth-resume-fix.service" +mkdir -p "$stub_bin" + +cat >"$stub_bin/lspci" <<'SH' +#!/bin/bash + +# Chatty like real lspci: keep writing well past the pipe buffer after the +# match, so a grep -q consumer would die of SIGPIPE and pipefail would read +# that as "no such hardware" (#6608). +if [[ -n ${BT_ID:-} ]]; then + echo "01:00.1 Network controller [0280]: Broadcom Inc. Bluetooth [14e4:$BT_ID]" +fi +for _ in {1..4096}; do + echo '02:00.0 Host bridge [0600]: Filler Device [ffff:0000]' +done +SH + +# The recovery is for Apple Silicon, so every case has to say which +# architecture it runs on rather than inherit the machine running the suite. +cat >"$stub_bin/uname" <<'SH' +#!/bin/bash + +if [[ ${1:-} == "-m" ]]; then + echo "${ARCH:-x86_64}" +else + exec /usr/bin/uname "$@" +fi +SH + +cat >"$stub_bin/systemctl" <<'SH' +#!/bin/bash + +printf 'systemctl' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" +if [[ ${1:-} == "is-enabled" ]]; then + (( ${SERVICE_ENABLED:-0} == 1 )) +fi +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" +"$@" +SH + +# Models the kernel journal in the order a real resume writes it: the suspend +# entry, then the tx timeouts during device resume, then "PM: suspend exit" +# last of all. A read anchored on the exit marker therefore finds nothing. +cat >"$stub_bin/journalctl" <<'SH' +#!/bin/bash + +if [[ -n ${TEST_LOG:-} ]]; then + printf 'journalctl' >>"$TEST_LOG" + printf '\t%s' "$@" >>"$TEST_LOG" + printf '\n' >>"$TEST_LOG" +fi + +quiet=0 +for arg in "$@"; do + [[ $arg == "-q" || $arg == "-kq" ]] && quiet=1 +done + +if [[ $* == *"--show-cursor"* ]]; then + if (( ${NO_SUSPEND_MARKER:-0} == 1 )); then + (( quiet == 0 )) && echo '-- No entries --' + exit 0 + fi + if [[ $* == *"suspend exit"* ]]; then + echo '-- cursor: s=stub;i=exit' + else + echo '-- cursor: s=stub;i=entry' + fi + exit 0 +fi + +lines=() +timeout='Bluetooth: hci0: command 0x0c01 tx timeout' +if [[ $* == *"--after-cursor"*"i=exit"* ]]; then + # The timeouts were logged ahead of the exit marker, so none sit behind it. + : +elif [[ $* == *"--after-cursor"* ]]; then + # Everything after the suspend entry: the whole suspend and resume path. + for ((j = 0; j < ${WEDGE_LINES:-0}; j++)); do + lines+=("$timeout") + done +elif [[ $* == *"--since"* ]]; then + # A --since window trusts the wall clock: empty when the clock stepped + # backwards across resume, the post-resume entries otherwise. + if (( ${SINCE_EMPTY:-0} == 0 )); then + for ((j = 0; j < ${WEDGE_LINES:-0}; j++)); do + lines+=("$timeout") + done + fi +fi + +if (( ${#lines[@]} == 0 )); then + (( quiet == 0 )) && echo '-- No entries --' + exit 0 +fi +printf '%s\n' "${lines[@]}" +SH + +cat >"$stub_bin/rfkill" <<'SH' +#!/bin/bash + +# An absent radio prints nothing at all, which is not the same as a blocked +# one. The default has to survive an empty RADIO_SOFT, so no colon here: that +# is exactly the case being modelled. +[[ -z ${RADIO_SOFT-unblocked} ]] && exit 0 +echo "${RADIO_SOFT-unblocked}" +SH + +# The command waits out real seconds between polls; the logic under test does +# not depend on the waiting. +cat >"$stub_bin/sleep" <<'SH' +#!/bin/bash + +exit 0 +SH + +chmod +x "$stub_bin"/* + +# The dependency guard goes through the repo's own helper, not the suite +# machine's installed copy. +ln -s "$ROOT/bin/omarchy-cmd-missing" "$stub_bin/omarchy-cmd-missing" + +# The leaf writes the unit under /etc/systemd/system; redirect that into the +# sandbox. run_logged sources leaves under bash -eE, and the migration runs +# the same file under pipefail, so exercise the stricter of the two. +sandboxed_leaf="$test_tmp/fix-bluetooth-resume.sh" +sed "s|/etc/systemd/system|$test_tmp/etc/systemd/system|g" "$leaf" >"$sandboxed_leaf" + +run_leaf() { + local arch="$1" bt_id="${2:-}" + rm -rf "$test_tmp/etc" + mkdir -p "$test_tmp/etc/systemd/system" + : >"$calls" + + ARCH="$arch" BT_ID="$bt_id" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + bash -eE -o pipefail -c 'source "$1"' bash "$sandboxed_leaf" /dev/null + [[ -f $service ]] || + fail "an Apple Silicon Mac gets the recovery service" "14e4:$bt_id" + grep -Fq $'systemctl\tenable\tomarchy-bluetooth-resume-fix.service' "$calls" || + fail "the recovery service is enabled" "$(cat "$calls")" +done +pass "an Apple Silicon Mac with wedging Broadcom Bluetooth gets the recovery service" + +# BCM4388 has only ever been reported wedging through the rfkill path, so it +# must not pick up a resume service it has no need for. +run_leaf aarch64 5f72 >/dev/null +[[ -f $service ]] && fail "BCM4388 is left out until a resume wedge is reported there" +run_leaf x86_64 5f69 >/dev/null +[[ -f $service ]] && fail "a T2 Intel Mac does not get the Apple Silicon recovery" +pass "the service is confined to the parts that wedge across resume" + +run_leaf aarch64 5f69 >/dev/null +# The service must never delay resume itself: ordered after the sleep targets, +# not hooked into the suspend path. +grep -q 'After=suspend.target' "$service" || + fail "the recovery is ordered after resume, not into the suspend path" +grep -q 'WantedBy=suspend.target' "$service" || + fail "the recovery is pulled in by the sleep targets" +grep -q 'ExecStart=/usr/bin/omarchy-bluetooth-resume-fix' "$service" || + fail "the service runs the recovery command" +pass "the recovery service runs on resume without delaying it" + +driver="$test_tmp/driver" +class="$test_tmp/class" +device="0000:01:00.1" + +run_fix() { + rm -rf "$driver" "$class" + mkdir -p "$driver" "$class" + : >"$driver/unbind" + : >"$driver/bind" + # A controller the class dir already knows about, so the wait after a + # rebind finishes instead of sitting out its full timeout. + touch "$class/hci0" + [[ ${DEVICE_BOUND:-1} == 1 ]] && touch "$driver/$device" + : >"$calls" + + PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + OMARCHY_BLUETOOTH_DRIVER_DIR="$driver" \ + OMARCHY_BLUETOOTH_CLASS_DIR="$class" \ + bash "$fix" /dev/null +rebound && fail "a healthy controller is left alone" +pass "no tx timeouts after resume leaves the controller alone" + +WEDGE_LINES=3 run_fix >/dev/null +rebound || fail "a wedged controller is rebound" "unbind=$(cat "$driver/unbind") bind=$(cat "$driver/bind")" +pass "tx timeouts after resume rebind the controller" + +# The kernel logs "PM: suspend exit" only after device resume, behind the tx +# timeouts, so the read has to start from the suspend entry to see them. +grep -Fq 'PM: suspend entry' "$calls" || + fail "the journal read is anchored on the suspend entry" "$(cat "$calls")" +grep -Fq 'PM: suspend exit' "$calls" && + fail "the journal read never anchors on the exit marker" "$(cat "$calls")" +pass "the journal read starts at the suspend entry, ahead of the timeouts" + +# The timeouts are flushed at resume and can already be in the journal before +# the service starts, which is why the read is anchored to a marker rather +# than to a cursor taken when the service starts. +output=$(WEDGE_LINES=3 run_fix) +[[ $output == *"0s after resume"* ]] || + fail "timeouts logged before the service started are still seen" "$output" +pass "timeouts flushed before the service starts are still seen" + +# No suspend marker, so the command falls back to a --since window; a clock +# that stepped backwards across resume leaves it empty. +output=$(NO_SUSPEND_MARKER=1 SINCE_EMPTY=1 WEDGE_LINES=3 run_fix) +rebound && fail "an empty fallback window does not rebind on its own" +[[ $output == *"watching from now"* ]] || + fail "the fallback says it has no suspend marker to read from" "$output" +output=$(NO_SUSPEND_MARKER=1 WEDGE_LINES=3 run_fix) +rebound || fail "the fallback window still catches a wedge" +pass "a missing suspend marker falls back to a time window" + +output=$(RADIO_SOFT=blocked WEDGE_LINES=3 run_fix) +rebound && fail "a radio the user turned off is left off" +[[ $output == *"soft blocked"* ]] || fail "a blocked radio says so" "$output" +output=$(RADIO_SOFT= WEDGE_LINES=3 run_fix) +rebound && fail "a machine with no Bluetooth radio does nothing" +pass "a blocked or absent radio is never rebound" + +output=$(DEVICE_BOUND=0 WEDGE_LINES=3 run_fix) +[[ $output == *"nothing to rebind"* ]] || + fail "an unbound driver has nothing to rebind" "$output" +pass "an unbound driver is reported rather than guessed at"