Repository navigation
fix: a connection the phone is holding is not a strap refusing to pair - #2002
Merged
Merged
Conversation
ryanbr
force-pushed
the
fix/held-link-not-a-bond-refusal
branch
4 times, most recently
from
September 8, 2026 08:50
0fd6f76 to
ac032f6
Compare
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
force-pushed
the
fix/held-link-not-a-bond-refusal
branch
from
September 8, 2026 08:53
ac032f6 to
4b0736d
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
247 (status=0)23 (status=4)23 (status=4)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 theconnection. 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 teardownand 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_HELLOis 5/MG only, so on this reporter'sWHOOP 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
pausedHintHandshakeUnansweredalready sets, which saysnaming 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 andrequires 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.