Skip to content

fix(apple): await active-device seed before WHOOP startup - #2647

Draft
kavemang wants to merge 3 commits into
ryanbr:mainfrom
kavemang:agent/whoop-startup-gate-2604
Draft

kavemang wants to merge 3 commits into
ryanbr:mainfrom
kavemang:agent/whoop-startup-gate-2604

Conversation

@kavemang

@kavemang kavemang commented Oct 2, 2026 •

Copy link
Copy Markdown

What this PR does

Automatic WHOOP startup waits for the store to load the active-device selection before connecting or discovering services. Shared preparation covers poweredOn, restoration, and a restored pending connection that completes during store opening. Readiness, radio state and the existing device policy are checked after preparation.

Startup refusals now report store-not-ready or central-not-poweredOn through the always-on connection log, including the startup entry point and observed radio state. Existing active-device refusal logging remains the policy evidence. Failed preparation can retry on foreground entry or protected-data availability.

Refs #2604. This covers the startup race; established-link cancellation and an outbound-write gate remain outside this PR. Android seeds its active-device selection synchronously before radio callbacks and does not share this initialization race.

Validation

  • Five focused startup-gate tests pass using the actual helper and test sources in a temporary Linux Swift package. They cover overlapping preparation, inactive-device selection, successful ordering, failed preparation with retry, radio refusal, and a radio change while preparation awaits. Refusal tests assert the reported reason and that later gates/actions do not run.
  • Swift syntax parse, doc-comment hygiene and whitespace checks passed locally. All five checks passed at head 4fbdc2e351f4f2f328352ac9d176b67418bf2f11: native macOS build with StrandTests, iOS simulator build, Tools Python, hygiene and i18n. The head and complete five-check roster were rechecked. Apple CI run.
  • Normal main merge was clean; guarded derived parity regeneration passed (289 known findings).

Awaiting maintainer hardware — draft

No phone or strap has been tested for this PR. The maintainer's review checklist requires real WHOOP 4.0 and 5.0/MG validation:

The PR remains draft until that hardware validation is supplied. Compile/test success does not establish BLE behavior.

@kavemang
kavemang marked this pull request as ready for review October 3, 2026 00:08
@ryanbr

ryanbr commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks @kavemang. I traced the race and it is real: on main, poweredOn connects and willRestoreState discovers services without waiting for the active-device seed, so an Oura-active install can get as far as CLIENT_HELLO, a clock write and a historical offload against the WHOOP before the coordinator stops the link. The shared-preparation approach is the right shape for it.

Holding this one for two reasons, neither of them about the design.

1. The CoreBluetooth path needs a strap

AGENTS.md is explicit that anything on the CoreBluetooth, offload or live-HR path must be validated on real hardware, because compile success proves nothing about connection behaviour. Your own notes say the same ("No strap or phone tested", "Draft pending"), so I am taking that at face value rather than merging on green CI. I have a 4.0 and a 5.0/MG here and can run it.

What I would want exercised, since the change moves all of these onto one path:

  • cold launch in the foreground, normal auto-connect;
  • background restoration after the app is killed, strap reconnecting on its own;
  • an already-connected restored link, confirming live HR and R-R actually resume;
  • launch with the phone locked so the store open meets data protection, then unlock, confirming the protectedDataDidBecomeAvailableNotification retry fires;
  • ordinary 4.0 and 5.0/MG reconnect, confirming no regression in time-to-connect;
  • the bug itself: an Oura ring set active, confirming no CLIENT_HELLO, clock write or offload reaches the WHOOP.

2. The gate refuses silently, and one of those refusals used to log

This is the part I would like changed before it lands. resume returns without a word when prepare fails or isAllowed is false. Of the three conditions, only one is covered: whoopConnectAllowed logs its own refusal through lastBlockedConnectReason. The other two are quiet.

  • central.state != .poweredOn previously logged Restore: central not poweredOn yet (state=...), which went on to say it was deferring to poweredOn. That line is removed and nothing replaces it.
  • A failed store open logs nothing at all, which your description describes as leaving automatic startup silent.

AGENTS.md's diagnostics rule cuts directly against that: "do not let a path go quiet: replacing a wrong line with no line removed the evidence that identified the bug." In a change whose whole subject is startup sequencing, the failure report it will produce is "my strap stopped connecting after this update", and there would be nothing in a strap log to separate a database that did not open from a radio that was not powered yet. Could you log inside resume on both, naming the reason that closed the gate? Rare-event evidence, so always-on rather than behind a Test Centre domain, since it costs a line only when startup actually refuses.

One behaviour change worth confirming on hardware

resumeSystemConnection is now shared by poweredOn, willRestoreState and the two retry observers, so the poweredOn-with-restored-connected path now also sets restoreNeedsResubscribe = true and calls adoptSourceIdentity, which it did not before. Unifying those looks correct to me, and #1881's note about didConnect never firing on the restore path argues for it. But restoreNeedsResubscribe is what drives the #613 off-to-on cycle that live HR depends on, so it is worth watching specifically in the already-connected restoration case above.

What I checked and found sound

BLEStartupGate itself is correct. The in-flight dedup holds, and clearing preparation once it completes is harmless rather than a bug, because openStore guards on collector == nil, which is also what makes the retry path cheap when the first open already succeeded. The four gate tests are honest about their scope: testSelectedWhoopResumesAfterStoreIsReady pinning the order as store, gate, discover is the one I would have asked for, and testFailedStoreDoesNotConsultDefaultGateAndCanRetryOnUnlock covers the case your description calls out. CI is green on both Apple legs at b29613bd5.

For reference, #2646 is merged, as f46671f62. That one needed no hardware, and its queued-force regression test with the overlap assertion made it quick to verify.

@kavemang
kavemang marked this pull request as draft October 4, 2026 06:14
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.

2 participants