docs/windows-ble-gatt-0x8000ffff.md
Findings from investigating the 2026-07-24 v10 test failure (Windows→iPhone hotspot
succeeded with fresh pairing; the reversed leg iPhone→Windows failed). Written to answer:
is the unpair-and-re-pair recovery in core/src/windows/bluetooth.rs a justified fix, or
a workaround papering over an unexamined bug?
Two distinct failures are described here — don't merge them. The
0x8000FFFFHRESULT exception against an iPhone (the original subject, mechanisms 1–3) is unresolved and intermittent. The Windows↔Linux failure of 2026-07-25 looks superficially similar in the UI but is a different bug with a known cause — Linux was deleting its half of the bond after every successful transfer with a non-macOS peer — and is fixed. See "Field observations".
Short answer: the failure matches a family of well-documented Windows BLE stack problems with bonded peripherals, at least two independent mechanisms plausibly apply to Flying Carpet's exact scenario, and unpairing is a precedented (community-endorsed) recovery. But Microsoft's own guidance for the closest-matching mechanism is a plain retry loop, no unpairing — so a cheaper retry rung before the unpair rung might avoid the re-pair PIN dialog in some or all recurrences. That retry rung is now implemented ahead of the unpair rung, with UI-visible diagnostics that will show which rung fixes real-world occurrences (see "Recovery ladder" below).
Sequence:
stopBluetooth() (iOS/FlyingCarpet/ViewController.swift): disconnects the
BLE link and — important — removeService() deletes the Flying Carpet GATT service
from the iPhone's GATT database.addServiceIfNeeded() in shared/Bluetooth.swift) and advertises;
Windows scans as central, recognizes the bonded device ("Already BLE paired with
Bluetooth device"), skips pairing, and calls
GetGattServicesWithCacheModeAsync(BluetoothCacheMode::Uncached)
(core/src/windows/central.rs, get_services_and_characteristics). That call throws
HRESULT 0x8000FFFF (E_UNEXPECTED, "Catastrophic failure") — as an exception, not a
GattCommunicationStatus failure — on its first and only attempt.Conditions that make this scenario unusual, all worth keeping in mind as contributing factors:
2026-07-24, second run (passed, ladder in place, cargo tauri dev with console):
same sequence re-run: fresh 6-digit pairing during the Windows→iPhone leg, then
iPhone→Windows reusing the bond. Enumeration succeeded on attempt 1 — no retry, no
unpair, no PIN dialog. Notable details from stdout:
AlreadyPaired came from the connected short-circuit: no "weren't connected" /
pairing prints appeared, so the leg-1 BLE link was still up when leg 2 scanned. Windows
never had to reconnect, so no RPA-resolution window ever opened. First confirmation
that this branch occurs in practice — and that GATT client enumeration over the
leftover reverse-role link works.watcher wasn't started. status: BluetoothLEAdvertisementWatcherStatus(0) shows stop_watching() right after scan()
is a no-op (status still Created), so the Received handler stays registered — the
assumption rescan() relies on holds on real hardware.2026-07-25, Windows ↔ Linux (failed — and it is a different bug): Windows→Linux succeeded with fresh pairing; the reversed leg Linux→Windows failed. The ladder fired for the first time, and what it found says the ladder was reasoning about the wrong thing.
The distinguishing evidence is a line that is absent from the log.
get_services_and_characteristics prints UUID: {…} once per service as it iterates
(core/src/windows/central.rs). Between got services and Flying Carpet service not found in peer's service list, the log has none — so the service collection was
empty. That is not "connected, but the peer's database lacks our service"; it is "no
GATT database was read at all". Attempt timings 1.4 s / 7.7 s / 0.5 s, with the 7.7 s
attempt showing the connect-timeout shape.
Root cause, and it is not in the Windows stack: core/src/linux/bluetooth.rs used to
end a successful transfer by removing the peer's bond unless the peer was macOS
(keep_bond = info.0 == "mac"). In leg 1 Linux was the central, so on completion it did
adapter.remove_device() and dropped its half of the bond. Windows kept its half, so
leg 2 hit AlreadyPaired, skipped pairing, and tried to encrypt the link with an LTK the
Linux box had forgotten. The link never encrypted, so there was no GATT database to read,
and WinRT reported that as success-with-an-empty-list rather than a failure status.
The premise behind that removal — recorded in the code as "Windows/Android re-pair per
transfer, so removing is safe" — is false for both platforms it names. Windows'
central path short-circuits on AlreadyPaired and reuses the bond; the Android code
contains no removeBond call anywhere. macOS was never a special case. It was the first
platform where the symptom happened to be legible, because CoreBluetooth names it
(CBError 14, "Peer removed pairing information") instead of silently returning nothing.
Consequences for this document's conclusions:
0x8000FFFF, an HRESULT
exception. This one is a clean call returning an empty list. Different failures;
mechanisms 1–3 below were derived from the former and do not explain the latter.Pairing result: Failed on that attempt and on every subsequent transfer.get_services_and_characteristics now checks GattDeviceServicesResult::Status() before
reading Services(), and reports an empty-but-successful list as its own condition,
naming a one-sided bond as the likely cause.Still unexplained: why re-pairing then failed repeatedly (Pairing result: Failed
across three transfer attempts), and why the ThinkPad remained listed under Windows'
Bluetooth devices after UnpairAsync returned Unpaired. UnpairAsync clears the
association belonging to the DeviceInformation it was called on; Windows can hold a
separate association record for the same physical device, which would keep it listed and
could keep a stale pairing state in play. unpair() now logs IsPaired afterward so the
next occurrence says whether the bond this path cares about actually went away. Settling
it needs Linux-side evidence too — bluetoothctl paired-devices / btmon during a failed
re-pair — which no log in this document has yet.
Manual recovery, until a run confirms the fix: remove the pairing on both sides —
Windows Settings → Bluetooth → Remove device, and bluetoothctl remove <address> on Linux
— then pair fresh. Removing only one side reproduces the original condition.
2026-07-25, second Windows ↔ Linux run (bond fix confirmed; a third bug behind it): starting from fully unpaired, Linux → Windows succeeded and bonded. The reverse leg Windows → Linux then hung: Linux stuck at "Started Bluetooth scan, waiting for sending device...", Windows at "Started advertising Bluetooth service". Windows was advertising correctly (the sender is the BLE peripheral, the receiver the central); Linux simply never recognized the advertisement.
Cause, in central::scan (core/src/linux/central.rs): the discovery loop only acted on
AdapterEvent::DeviceAdded. For a bonded peer that event fires exactly once — as the
pre-seeded cache entry — because BlueZ resolves the peer's rotating private address back to
the existing device object using the stored IRK. No amount of live advertising produces a
second DeviceAdded. The loop then dismissed the entry (Ignoring device … without Flying Carpet service) and waited forever on an event that could never arrive.
Whether the cached entry carries our service UUID depends on how the bond was made, which is why the two runs disagree:
| Bond created during | Linux's cache entry for the peer | Result |
|---|---|---|
| a leg where Linux was central (it discovered the peer's FC advertisement) | has the FC service UUID | pre-seeded DeviceAdded is accepted — works |
| a leg where Linux was peripheral (the peer connected in) | records the peer's GATT server, no FC UUID | dismissed — hangs |
The first run bonded in the first arrangement and the reverse leg worked; this run bonded in the second and it hung. Same bond, opposite provenance, opposite outcome.
The cached entry cannot be purged the way the unpaired ones above it are: remove_device
takes the bond with it, which is the bug fixed immediately above. The fix is instead to
re-read the known devices on a 500 ms timer alongside the event stream, so the peer is picked
up when BlueZ refreshes the entry's UUIDs from a live advertisement. A device whose cached
UUIDs lack our service and later gain it must have just advertised, so this adds no
staleness risk the pre-seeded DeviceAdded path doesn't already carry.
Note the theme across all three bugs in this document: bonded peers take different code paths from unbonded ones, on every stack, and each platform's discovery/enumeration logic was written and tested against the unbonded path first. Keeping bonds (the fix above) makes the bonded path the normal one, so it needs the same scrutiny.
2026-07-25, resolution: it was the bearer, not the cache. The fix above (and two
iterations after it) chased the wrong property. Device1.UUIDs is BlueZ's resolved GATT
service list, not advertisement data — bluer documents it as "the available remote services",
with ServiceData as the separate advertisement property — so for a bonded peer it reflects a
cached attribute database and no read of it, polled or event-driven, could ever show the
service appearing. That much was right. But the fix built on it, connecting to force
re-resolution, failed with the error that gave the real answer:
Paired device E8:48:B8:C8:20:00 doesn't list our service; connecting to re-resolve its GATT database
Could not probe E8:48:B8:C8:20:00: Bluetooth operation failed: br-connection-canceled
br- is BR/EDR. Device1.Connect() was paging classic Bluetooth at a peer that serves
GATT only over LE — the failure this document's own Linux section already describes for macOS,
now reproduced against Windows.
Mechanism, and it explains the provenance asymmetry exactly:
find_characteristics, and is therefore LE-only. select_conn_bearer's "prefer the
bonded bearer when exactly one is bonded" rule then pins every later Connect() to LE.The LE bonding socket was gated on address_type() == LePublic, with the comment "random
-address peers (Windows/Android/iOS) always connect over LE anyway". Windows advertises from
E8:48:… — 0xE8 is 0b11101000, a static random address — so it was excluded, and the
assumption holds only while a peer is unbonded. A dual-transport bond re-poisons the bearer
choice for any address type.
Fix: ensure_le_link() raises the LE ACL link with an L2CAP LE socket before Connect() for
any already-paired peer, regardless of address type — the same mechanism as the bonding
socket, applied to a hazard the bond itself creates rather than one that predates it.
Two lessons worth keeping. First: read the error string's prefix. Three fixes were built
on a theory that br-connection-canceled would have refuted immediately. Second: this
codebase already knew. 967ed6b documented the bearer tiebreak, CTKD producing dual bonds,
and the L2CAP-socket cure a month earlier; the only thing missing was applying it to bonded
peers. When a BLE failure looks novel here, check the Linux Bluetooth history first.
The 0x8000FFFF recovery ladder has still not been exercised against the failure it was
built for; the iPhone case remains intermittent and unreproduced since.
GetGattServicesAsync() reliably fails: Windows' first connection attempt goes to the
peripheral's unresolved random address and fails; the RPA/IRK resolution
(BthIRKResolution in Event Viewer) completes internally and the next attempt
succeeds. Microsoft's response confirms no public API exposes the resolution event and
officially recommends a retry loop (~3 attempts, ~1 s delay, Uncached). Unpairing
is not mentioned — not needed in that thread.CLOSED; "works when unpairing before disconnecting." Independent
confirmation that bonded + LE privacy + reconnect is the problem combination in the
WinRT stack, and that unpairing sidesteps it.GattServicesChanged and
clears all cached services; API calls racing that window fail with access-denied /
"method called at an unexpected time" errors. Relevant because the iPhone's database
genuinely changed between bond creation and transfer 2, so a Service Changed
indication lands right around Windows' reconnection and discovery.0x8000FFFF) does come out of
GetGattServicesAsync/connect paths under stack-internal contention, distinct from the
cleaner Unreachable status.Honest caveats: this is one observed occurrence, with UI logs only (no stdout, no
timing, no packet capture). The observed failure is an HRESULT exception, while the
best-matched Microsoft thread describes a GattCommunicationStatus::Unreachable
status on a 7-second timeout — same scenario, different failure surface, so the match
is strong but not exact. Mechanisms 1 and 2 are inferred from external reports, not
proven against this hardware.
The recovery added on 2026-07-24 (in negotiate_bluetooth, central branch): on
enumeration failure against an already-paired device, unpair → rescan → re-pair (new PIN
confirmation) → retry discovery, once; bond still persists across successful transfers.
GetGattServicesWithCacheModeAsync (Microsoft's own recommendation) would recover
without destroying the bond or showing a PIN dialog. The recovery ladder below was
added to settle this empirically.negotiate_bluetooth (central branch, core/src/windows/bluetooth.rs) now escalates
through:
get_services_and_characteristics no longer unpairs internally on that result — the
ladder owns the unpair decision.What the UI log records on a failure, and how to read it:
Couldn't read Bluetooth services (attempt N/3, X.Xs), retrying... — one line per
failed attempt (no , retrying... on the last one). The timing distinguishes an
immediate stack rejection (<1 s) from the ~7 s connect-timeout shape of mechanism 1.
The error text itself is not here — see stdout below.Reading Bluetooth services succeeded on attempt N — the money line. If this appears,
a benign mechanism (1/2) is confirmed for that occurrence and no PIN dialog was
needed.Couldn't read services of already-paired Bluetooth device. Unpairing and pairing again... — the unpair rung fired; if the transfer then completes, that occurrence is
evidence the bond itself was the problem (mechanism 3).Could not establish Bluetooth connection: <error> (from start_transfer) — the
ladder ran out of rungs. This is the only place the full error text, with its
one-sided-bond explanation, reaches the UI, and the only failure the user must act on.Trimmed on 2026-07-25: each attempt used to repeat the full multi-sentence error, plus a
Diagnostic info: line, in the UI — three paragraphs of bond-troubleshooting prose for a
transfer that then succeeded on attempt 3 and was fine. Both now go to stdout:
attempt N failed after X.Xs: <error> — distinguishes HRESULT failures from a
missing-service list.diagnostic info: {reused|new} bond; peer {was already|was not} connected when discovered — printed with the first failure; settles which AlreadyPaired branch
fired (the still-connected short-circuit vs. reconnect-to-bonded-device).So a field report that only has the UI log still shows which rung fixed it and the shape of each failure; ask for stdout when the cause is in question.
Interpretation over time: if field logs show rung 1 succeeding, the unpair rung can be
demoted to rarely-hit insurance; if rung 1 never succeeds and rung 2 always does,
mechanism 3 (the role-reversed bond is itself defective) becomes the leading theory and
re-pairing is genuinely required. stdout still carries extra detail if a console is
attached (e.g. RequestAccessAsync's result in the scan callback).