Skip to content

fix: a connection the phone is holding is not a strap refusing to pair - #2002

Merged
ryanbr merged 1 commit into
mainfrom
fix/held-link-not-a-bond-refusal
Sep 8, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/held-link-not-a-bond-refusal

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

Reported in #1997 by @semoi: a WHOOP 4.0 reconnects, the strap sends nothing, and after four cycles NOOP
pauses auto-reconnect and shows the re-pair guide. They had been re-pairing and rebooting several times a
day on that advice.

What the log actually shows

link MTU inbound
gen=1 247 (status=0) 6 frames / 26 bytes
gen=2 23 (status=4) 0 frames
gen=3 23 (status=4) 0 frames

Every re-attach is answered with a refused MTU exchange and sits at the 23-byte default, and on exactly
those links nothing arrives. The first bounce reports ACL-held, so the phone's stack still holds the
connection. NOOP re-attaches to a connection the strap is not on. No part of that is a strap declining to
pair.

NOOP cannot clear that connection, and this does not try to: gatt.close() is already called on teardown
and before each new connect, so NOOP is not the one holding it, and Android gives an app no way to drop a
connection it does not own.

Three changes

1. The pause stays, and the guide changes. The first version of this fix excluded these links from the
never-bonded give-up, reasoning that nothing was exchanged for the strap to refuse. That was wrong, and
only visible by asking what else would stop the loop: CLIENT_HELLO is 5/MG only, so on this reporter's
WHOOP 4.0 the other give-up never fires, and excluding them would leave the connect-drop-retry cycle
unbounded and draining both batteries. That is exactly the failure #982 exists to prevent. The pause is
correct. Only the explanation was wrong, so the guide swaps and the counting does not.

2. The guide and hint say the connection is held, rather than blaming the strap, and say re-pairing
will not change it, replacing the four re-pair steps for this state only. It still does not assert WHICH owner holds the link, because NOOP cannot see that: it names
both candidates and the action for each.

That restraint is the standard the neighbouring pausedHintHandshakeUnanswered already sets, which says
naming a cause is "a guess dressed as instruction" for an unanswered handshake. This branch meets that
bar rather than bending it: a refused MTU exchange with zero traffic is a positive observation that
something holds the link, not an inference from silence.

3. The MTU line reads as the failure it is. MTU negotiated: 23 (status=4) looks like a result and
requires the reader to know 23 is the fallback. This also revisits the call made in #2000 to leave MTU
status bare; on this log it is the single most diagnostic line.

Tests

+6. The predicate needs BOTH halves, so a refused exchange that still carried frames and a silent link
that negotiated fine are both left alone, the latter being the #1809 shape. A held link is pinned as
STILL counting toward the give-up, which is the correction above and the thing that keeps the loop
bounded. The guide is pinned as replacing the re-pair steps rather than repeating them, and as still
giving the user something to do.

One of these tests caught a real mistake while being written: it asserted the hint never contains the word
"re-pair", but the hint deliberately contains it in order to rule the action out. The assertion now pins
what was meant, which is that it never instructs one.

672 classes / 5693 tests, 0 failures.

Android-only: the refused MTU re-exchange is how this state presents on Android, and CoreBluetooth
negotiates MTU itself with no equivalent status to read.

Still open with the reporter: whether the official WHOOP app is installed, which would confirm the
owner of the held connection. The fix does not depend on the answer, since it describes what was observed
rather than asserting a cause.

@ryanbr
ryanbr force-pushed the fix/held-link-not-a-bond-refusal branch 4 times, most recently from 0fd6f76 to ac032f6 Compare September 8, 2026 08:50
Reported in #1997 by @semoi. A WHOOP 4.0 reconnects, the strap sends nothing, and
after four cycles NOOP pauses auto-reconnect and shows the re-pair guide. They had
been re-pairing and rebooting several times a day on that advice.

The log says what actually happened. The first link negotiates MTU 247 and carries
frames; every re-attach after it is answered with a refused MTU exchange, sits at
the 23-byte default, and carries nothing at all. The first bounce also reports
ACL-held, so the phone's stack still holds the connection. NOOP re-attaches to a
connection the strap is not on, and no part of that is the strap declining to pair.

The pause itself stays. Excluding these links from the give-up was the first shape
of this fix and it was wrong: CLIENT_HELLO is 5/MG only, so on this reporter's 4.0
nothing else would have bounded the loop, and the connect-drop-retry cycle would
have run forever draining both batteries, which is the exact failure #982 exists
to prevent. The pause is right. Only the explanation was wrong.

So when the MTU exchange was refused and nothing arrived, the guide now says the
connection is being held rather than blaming the strap, and says re-pairing will
not change it, instead of the four re-pair steps. It still
does not assert WHICH owner holds the link, because NOOP cannot see that: it names
both candidates and the action for each. That is the standard the neighbouring
unanswered-handshake hint already sets, and this branch meets it because a refused
MTU exchange with no traffic is an observation rather than a guess.

Which guide to show is a pure function rather than an inline branch in a BLE
callback, so the SELECTION is pinned and not only the two texts. Pinning a
predicate and a string separately proves neither is wired to the other, and the
callback cannot be reached from a JVM test at all.

The MTU line now reads as the failure it is. "MTU negotiated: 23 (status=4)" looks
like a result and requires the reader to know that 23 is the fallback.

Android-only: the refused MTU re-exchange is how this state presents on Android,
and CoreBluetooth negotiates MTU itself with no equivalent status to read.
@ryanbr
ryanbr force-pushed the fix/held-link-not-a-bond-refusal branch from ac032f6 to 4b0736d Compare September 8, 2026 08:53
@ryanbr
ryanbr merged commit 1646f6a into main Sep 8, 2026
3 checks passed
@ryanbr
ryanbr deleted the fix/held-link-not-a-bond-refusal branch September 8, 2026 09:00
ryanbr added a commit that referenced this pull request Sep 8, 2026
#2002 inferred a held connection from a refused MTU exchange with no inbound
traffic. That shape also describes a STALE PAIRING, and the #1997 reporter turned
out to be exactly that: no official WHOOP app installed, a bond that had gone
stale, and unpairing plus re-pairing is what got them connected again.

So the guide would have told the one user it was written for not to re-pair, which
was the thing most likely to fix them, and to quit an app they do not have. Worse
advice than the guide it replaced.

The OS signal is now required rather than inferred. isStrapAclHeld already answers
"is the connection still held" directly, so a refused exchange with no traffic
only reads as a held link when the stack confirms it. A stale pairing keeps the
re-pair guide.

The wording leads with turning Bluetooth off and on, which works whoever holds the
connection, and mentions the official app second and conditionally.

The pause also logs what the choice turned on. Nothing in the field log actually
shows the ACL signal on the silent links, because those took the give-up path,
which prints no ACL marker. So the narrowed branch may be right and may simply
never fire, and without this line the next log would not say which.
ryanbr added a commit that referenced this pull request Sep 8, 2026
#2002 inferred a held connection from a refused MTU exchange with no inbound
traffic. That shape also describes a STALE PAIRING, and the #1997 reporter turned
out to be exactly that: no official WHOOP app installed, a bond that had gone
stale, and unpairing plus re-pairing is what got them connected again.

So the guide would have told the one user it was written for not to re-pair, which
was the thing most likely to fix them, and to quit an app they do not have. Worse
advice than the guide it replaced.

The OS signal is now required rather than inferred. isStrapAclHeld already answers
"is the connection still held" directly, so a refused exchange with no traffic
only reads as a held link when the stack confirms it. A stale pairing keeps the
re-pair guide.

The wording leads with turning Bluetooth off and on, which works whoever holds the
connection, and mentions the official app second and conditionally.

The pause also logs what the choice turned on. Nothing in the field log actually
shows the ACL signal on the silent links, because those took the give-up path,
which prints no ACL marker. So the narrowed branch may be right and may simply
never fire, and without this line the next log would not say which.
ryanbr added a commit that referenced this pull request Sep 8, 2026
#2002 inferred a held connection from a refused MTU exchange with no inbound
traffic. That shape also describes a STALE PAIRING, and the #1997 reporter turned
out to be exactly that: no official WHOOP app installed, a bond that had gone
stale, and unpairing plus re-pairing is what got them connected again.

So the guide would have told the one user it was written for not to re-pair, which
was the thing most likely to fix them, and to quit an app they do not have. Worse
advice than the guide it replaced.

isStrapAclHeld already answers "is the connection still held" directly, so that is
what the predicate asks. A stale pairing keeps the re-pair guide.

The MTU refusal is no longer part of the test. It was how this INFERRED a held
link before asking the OS directly; once the direct signal is required it adds no
part of the claim and only narrows when the branch can fire, which on a branch
with no field sighting yet is cost without benefit. It still reads as a refusal in
the log, where it helps triage.

The paired hint variant goes with it. Its branch sat after authRefusal, which
takes precedence on the only path a WHOOP 4.0 reaches, and the other caller needs
an unacked CLIENT_HELLO, which is 5/MG only. It could not fire for the case it was
written for. The guide, which is the surface the reporter actually saw, covers it.

The wording leads with turning Bluetooth off and on, which works whoever holds the
connection, and mentions the official app second and conditionally.

The pause also logs what the choice turned on. Nothing in the field log shows the
ACL signal on the silent links, because those took the give-up path, which prints
no ACL marker. So the branch may be right and may simply never fire, and without
this line the next log would not say which.
ryanbr added a commit that referenced this pull request Sep 8, 2026
…2003)

#2002 inferred a held connection from a refused MTU exchange with no inbound traffic. That shape also
describes a STALE PAIRING, and the #1997 reporter turned out to be exactly that: no official WHOOP app
installed, a bond gone stale, and unpairing plus re-pairing is what reconnected them. So the guide would
have told the one user it was written for not to re-pair, and to quit an app they do not have.

isStrapAclHeld answers "is the connection still held" directly, so that is what the predicate asks now.
A stale pairing keeps the re-pair guide, byte-identical to the one it always had.

The MTU refusal is no longer part of the test. It was how this inferred a held link before asking the OS
directly; once the direct signal is required it adds no part of the claim and only narrows when the branch
can fire. It still reads as a refusal in the log, where it helps triage.

The paired hint variant goes with it: its branch sat after authRefusal, which takes precedence on the only
path a WHOOP 4.0 reaches, and the other caller needs an unacked CLIENT_HELLO, which is 5/MG only. It could
not fire for the case it was written for.

The pause itself is unchanged, byte-identical to before, so nothing that paused stops pausing. The pause
now logs what the guide choice turned on, because nothing in the field log shows the ACL signal on the
silent links and without that line the next log would not say whether this branch fires at all.
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