fix(pair-code): keep a phone-number link alive past the QR rotation - #1163
Conversation
The pair-code flow shares its connection with QR rotation but not its lifetime, and four places treated the two as one. Cross-checked against the captured WhatsApp Web bundle (`WAWeb/Alt/DeviceLinking*.js`, `WAWeb/Handle/PairDevice.js`, `WAWeb/Handle/CompanionReqRefreshNotification.js`, `WAWeb/Link/DevicePhoneNumberCodeScreen.react.js`). 1. Exhausted QR refs no longer disconnect a connection carrying an outstanding pair code. WA Web's rotation timer cancels itself and reports UNPAIRED_IDLE; it never closes the socket. Ours closed it at 60s + 5x20s, revoking a code advertised as valid for 180s. A new `Event::PairingQrCodesExhausted` reports the state either way, and QR-only callers keep the self-disconnect they rely on. 2. `pair_with_code` refuses to supersede a live code (`PairCodeError::CodeAlreadyOutstanding`), mirroring WA Web's `invariant(stage === Initialized)` on `startAltLinkingFlow`. A second code does not replace the first for the phone: the server routes `primary_hello` by number, so the older code still reaches stage 2 and is answered with a bundle it cannot open. `Client::cancel_pair_code` is the explicit reset, our `initializeAltDeviceLinking()`. 3. An unanswered `companion_finish` now times out. WA Web arms one minute on `primary_hello_received` and regenerates the code when it fires; we waited forever, leaving the consumer with a code that would never complete and no signal that anything went wrong. `Event::PairingCodeRefresh` covers both triggers now, and the flow is cleared before it fires so the replacement is not rejected by the flow it replaces. 4. `<notification type="companion_reg_refresh">` is handled, accepting either the `companion_reg_refresh` or `pair-device-rotate-qr` child WA Web's parser takes, and re-minting the ADV secret the QR advertises. It yields to an outstanding pair code, whose derived secret the pending pair-success HMAC is computed over. Also moves stage 2 off the notification path into a task, so the ack is no longer held behind a 131k-round PBKDF2 — WA Web starts `handlePrimaryHello` without awaiting it and returns the ack in the same expression. The `pair_code_state` lock still serializes derive, persist and send, and the task re-checks the ref it was spawned for.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds companion registration refresh handling, coordinates ADV secret rotation with active pair-code flows, prevents overlapping live pairing codes, moves stage-two processing off the acknowledgment path, and adds QR exhaustion events with conditional disconnection. ChangesPairing refresh flow
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested labels: Sequence Diagram(s)sequenceDiagram
participant NotificationDispatcher
participant RefreshHandler
participant PairCodeState
participant DeviceStore
participant QRRotationTask
NotificationDispatcher->>RefreshHandler: dispatch companion_reg_refresh
RefreshHandler->>PairCodeState: check pending pair-success
alt no pending pair-success
RefreshHandler->>DeviceStore: persist new ADV secret
RefreshHandler->>QRRotationTask: request displayed QR re-render
else pending pair-success
RefreshHandler-->>NotificationDispatcher: preserve ADV secret
end
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| src/pair_code.rs | Adds live-flow exclusion, cancellation, detached stage-two processing, and timeout recovery, but leaves races in concurrent initialization and stale-task ownership checks. |
| src/pair.rs | Changes QR exhaustion to preserve connections carrying a live pair-code flow and emits a typed exhaustion event. |
| src/handlers/notification/companion_reg.rs | Adds validated companion registration refresh handling with an intentional live-pair-code exception. |
| src/handlers/notification/mod.rs | Routes companion_reg_refresh notifications and adds coverage for accepted and rejected child tags. |
| wacore/src/pair_code.rs | Adds pair-code lifetime calculation, timeout configuration, and the outstanding-code error contract. |
| wacore/src/types/events.rs | Adds frozen event payloads for exhausted QR references and broadens pair-code refresh semantics. |
Sequence Diagram
sequenceDiagram
participant App
participant Client
participant WA as WhatsApp Server
participant Stage2 as Detached Stage 2
App->>Client: pair_with_code()
Client->>WA: companion_hello
WA-->>Client: pairing_ref
Client-->>App: display code
WA->>Client: primary_hello
Client-->>WA: notification ack
Client->>Stage2: derive and persist keys
Stage2->>WA: companion_finish
alt pair-success received
WA-->>Client: pair-success
Client-->>App: PairSuccess
else no response for 60 seconds
Client-->>App: PairingCodeRefresh
end
Prompt To Fix All With AI
### Issue 1
src/pair_code.rs:195-202
**Concurrent requests bypass code guard**
When two callers invoke `pair_with_code` while the state is idle, both release the mutex before awaiting their independent `companion_hello` requests and can both pass the one-code guard. Their responses then overwrite the same `WaitingForPhoneConfirmation` state, so entering the earlier returned code uses the later flow's key material and fails to link.
### Issue 2
src/pair_code.rs:539-546
**Stale stage-two task changes replacement**
When a flow is cancelled or refreshed after scheduling stage two and a replacement reaches `WaitingForPhoneConfirmation`, this variant-only check treats the replacement as the captured flow. The stale task then persists its retired ADV secret and sends its old `companion_finish`, causing the valid replacement to fail verification or time out.
```suggestion
let state_guard = client.pair_code_state.lock().await;
// Pair-success, cancellation, or replacement may have landed while this
// task waited for the lock.
if !matches!(
&*state_guard,
PairCodeState::WaitingForPhoneConfirmation {
pairing_ref: current_ref,
..
} if current_ref.as_slice() == pairing_ref.as_slice()
) {
return;
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(pair-code): keep a phone-number link..." | Re-trigger Greptile
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/handlers/notification/mod.rs`:
- Around line 93-95: Move the rationale comment from the `use companion_reg::*;`
re-export to directly above `use device::*;`, which re-exports
`handle_local_identity_change`. Keep the comment text and re-export behavior
unchanged.
In `@src/pair_code.rs`:
- Around line 1284-1291: Replace the no-op poll_until call in the
completed-pairing test with an explicit async yield that gives the timer task a
chance to run before the assertion. Keep the existing assertion and its expected
no-refresh behavior unchanged.
- Around line 539-546: Update the re-entry check in the pairing task around the
state_guard to require that WaitingForPhoneConfirmation contains the same
pairing ref captured by this task, rather than matching the variant alone.
Mirror the still_ours comparison used by start_pair_success_timeout, returning
early for a different or retired ref before deriving secrets or sending
companion_finish.
- Around line 195-202: Make the check-and-reservation in pair_with_code atomic
so concurrent calls cannot both mint a code. Update the flow around
live_flow_remaining and the later pair_code_state write to use a dedicated
serialization lock or reserve the slot while holding the same pair_code_state
lock before PBKDF2/send_iq; preserve the CodeAlreadyOutstanding error for
subsequent callers and ensure the reservation is finalized or released
appropriately on failure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: a784ba5d-5c4b-48a6-9572-e2df0bbc4b8c
📒 Files selected for processing (6)
src/handlers/notification/companion_reg.rssrc/handlers/notification/mod.rssrc/pair.rssrc/pair_code.rswacore/src/pair_code.rswacore/src/types/events.rs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9545c9a58
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
📦 Binary size report
.text per crate
Top movers (cargo-bloat attribution)
Baseline: |
Six follow-ups, all reachable and all in code this branch introduced. The one-code-at-a-time guard checked the state and released the lock across the stage-1 round trip, so two concurrent callers both found it idle and both minted a code — the exact overlap the guard exists to prevent. The slot is now claimed under the same lock that reads it, via a `RequestingCode` state that is handed back when stage 1 fails and refuses to install its flow when it was withdrawn meanwhile. The stage-2 task matched on the state variant alone, so a flow that had been replaced rather than merely retired read as its own: it would have persisted a retired adv secret over the replacement's and put a `companion_finish` on the wire for a ref nobody holds. It matches the ref now, like the pair-success timer already did. `companion_reg_refresh` yielded only to a code inside its validity window, but a `primary_hello` accepted near the end of that window leaves the link pending for up to a minute longer, with the adv secret its HMAC is computed over already derived. `awaiting_pair_success` tracks that half of the flow separately from the code's own clock. QR payloads were built once, from a single snapshot, and queued. Since `companion_reg_refresh` re-mints the adv secret, every queued payload would have advertised a retired one. Each payload is now built when its ref is published, from the state of that moment, which is also what WA Web does. The pair-success timeout was armed after a successful `companion_finish` send, so a stage 2 that failed armed nothing at all. WA Web starts the clock on the notification (`primaryHelloReceivedAltLinking` fires before `handlePrimaryHelloInternal`), so it is armed on acceptance now. Also fixes a test that used `poll_until(.., || true)` — a no-op that let the negative assertion pass without the timer task ever being polled — and moves a re-export rationale back above the import it explains.
There was a problem hiding this comment.
Greptile has paused reviews on this repository — it used its 750 free open-source review credits for this billing period. Reviews resume automatically on August 2. To continue before then, an organization admin can keep reviews running past the free credits — those bill as normal usage.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 108437b5b3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Seven more from review, all reachable. A claim was identified by the second it started in, so cancelling a request and starting its replacement inside the same second let the first one's late response install its code over the replacement's claim, and its failure path release it. Claims carry a token now. Three places confused the code's clock with the link's. `pair_with_code` and the QR-exhaustion guard both read a flow as gone once the 180s window closed, even though an accepted `primary_hello` leaves `companion_finish` pending for up to a minute more: one would start a second flow racing the pending pair-success, the other would tear down the socket carrying it. Both ask `is_outstanding` now. The `companion_reg_refresh` handler had the opposite error, deferring to a code that was merely displayed — that flow derives its own adv secret when the phone answers, so deferring protected nothing while leaving the QR on the same connection advertising material the server had retired. It defers only to a pending pair-success. The pair-success timers were keyed on the shared pairing ref, so a retry accepted partway through the first attempt's window was retired by the first attempt's timer. They carry the attempt they belong to. Rotating the adv secret rebuilt the queued QR payloads but not the one on screen, which stayed scannable against a retired secret for the rest of its ref. The rotation task re-renders the current ref in place, which is how WA Web drives it — one path serves both a ref change and an adv-secret change. Finally, a pair-code flow outlived the connection that carried it: the ref and any in-flight companion_hello belong to a socket the server has already dropped, and leaving them behind made the outstanding-code guard reject the request reconnecting exists to make.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4cbe76d90a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The re-render loop reached for `tokio::time::sleep_until` and `Instant`, which the wasm32 and no-default-features builds do not have. The ref's remaining slice is what a re-render has to resume against anyway, so it tracks elapsed time against the runtime's own clock and sleeps through the Runtime trait like the rest of the task.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/pair_code.rs (1)
879-890: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winEvery new test fixture uses
15551234567, which isn't the reserved fictional-number format.The shared root cause is an NPA of
555. This repo's convention is1+ a real NPA + the fictional555exchange + a line number in0100-0199— e.g.12025550111.1555...doesn't match that, so these aren't provably unassignable. Cheap to fix now, annoying to chase later once more tests copy the helper.
src/pair_code.rs#L879-L890: changephone_jidinset_waitingto"12025550111".src/pair_code.rs#L1168-L1173: changephone_numberinoptions()to"12025550111".src/pair_code.rs#L1560-L1569: change therun_stage_twophone argument to"12025550111".src/handlers/notification/mod.rs#L196-L206: changephone_jidincompanion_reg_refresh_waits_for_a_pending_pair_successto"12025550111".src/handlers/notification/mod.rs#L230-L239: changephone_jidina_merely_displayed_code_does_not_defer_the_rotationto"12025550111".src/pair.rs#L589-L600: changephone_jidinset_pair_code_waitingto"12025550111".src/pair.rs#L709-L721: changephone_jidinqr_exhaustion_keeps_the_socket_up_for_a_pending_pair_successto"12025550111".Based on learnings: "In Rust test code that uses fictional NANP phone numbers, format numbers as:
1(country code) + a real NPA (not555) + the fictional555exchange + a 4-digit line number between0100and0199. ... Do not use an NPA of555(e.g.,1555...), since it does not match the reserved fictional-number format."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair_code.rs` around lines 879 - 890, Replace every test fixture phone number 15551234567 with the reserved fictional number 12025550111: update set_waiting, options(), and run_stage_two in src/pair_code.rs (lines 879-890, 1168-1173, and 1560-1569), companion_reg_refresh_waits_for_a_pending_pair_success and a_merely_displayed_code_does_not_defer_the_rotation in src/handlers/notification/mod.rs (lines 196-206 and 230-239), and set_pair_code_waiting and qr_exhaustion_keeps_the_socket_up_for_a_pending_pair_success in src/pair.rs (lines 589-600 and 709-721).Source: Learnings
wacore/src/pair_code.rs (1)
224-256: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winTwo doc blocks got merged onto the wrong function.
Lines 225-239 describe the code-validity window and end with "The boundary matches [
PairCodeUtils::code_validity] as applied in stage 2" — that'slive_flow_remaining's doc. It's now sitting on top ofawaiting_pair_success, andlive_flow_remainingat Line 266 has no doc at all. Rustdoc will publish the wrong explanation on the wrong item. This needs to move to where the decision it explains actually lives.As per coding guidelines: "Place comments explaining the rationale for a decision at the single point where that decision is made."
📝 Proposed fix: move the stranding rationale onto `live_flow_remaining`
impl PairCodeState { - /// The window left on a code someone may still be reading, or `None` when - /// there is nothing left to strand. - /// - /// A second `companion_hello` mints a new code *and* a new ephemeral - /// keypair, but the server keeps routing `primary_hello` by phone number — - /// it never sees the code itself. So the holder of the superseded code - /// still reaches stage 2, and gets a key bundle derived from key material - /// their code cannot open: the primary fails to link with no error the - /// companion can see. WA Web forbids the overlap outright, guarding - /// `startAltLinkingFlow` with `invariant(stage === Initialized)` - /// (`Alt/DeviceLinkingApi.js`) so a replacement must follow an explicit - /// `initializeAltDeviceLinking()`. - /// - /// The boundary matches [`PairCodeUtils::code_validity`] as applied in - /// stage 2, which rejects only `age > validity`. /// Whether a `companion_finish` is out and its `pair-success` still due.and above
live_flow_remaining:+ /// The window left on a code someone may still be reading, or `None` when + /// there is nothing left to strand. + /// + /// A second `companion_hello` mints a new code *and* a new ephemeral + /// keypair, but the server keeps routing `primary_hello` by phone number — + /// it never sees the code itself. So the holder of the superseded code + /// still reaches stage 2, and gets a key bundle derived from key material + /// their code cannot open. WA Web forbids the overlap outright, guarding + /// `startAltLinkingFlow` with `invariant(stage === Initialized)` + /// (`Alt/DeviceLinkingApi.js`). + /// + /// The boundary matches [`PairCodeUtils::code_validity`] as applied in + /// stage 2, which rejects only `age > validity`. pub fn live_flow_remaining(&self, now: i64) -> Option<std::time::Duration> {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wacore/src/pair_code.rs` around lines 224 - 256, Move the code-validity and stranding rationale currently attached to `awaiting_pair_success` onto the `live_flow_remaining` method, preserving its explanation of the `PairCodeUtils::code_validity` boundary and stage-2 behavior. Keep only the pair-success/adv-secret documentation above `awaiting_pair_success`, and ensure both methods’ rustdoc describes the decision each method implements.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/pair.rs`:
- Around line 125-179: Fix the indentation of the loop body in the QR rotation
logic so all statements inside loop, including the snapshot creation, event
dispatch, select handling, and closing braces, are consistently nested under
loop. Ensure the resulting formatting matches cargo fmt without changing
behavior.
---
Outside diff comments:
In `@src/pair_code.rs`:
- Around line 879-890: Replace every test fixture phone number 15551234567 with
the reserved fictional number 12025550111: update set_waiting, options(), and
run_stage_two in src/pair_code.rs (lines 879-890, 1168-1173, and 1560-1569),
companion_reg_refresh_waits_for_a_pending_pair_success and
a_merely_displayed_code_does_not_defer_the_rotation in
src/handlers/notification/mod.rs (lines 196-206 and 230-239), and
set_pair_code_waiting and
qr_exhaustion_keeps_the_socket_up_for_a_pending_pair_success in src/pair.rs
(lines 589-600 and 709-721).
In `@wacore/src/pair_code.rs`:
- Around line 224-256: Move the code-validity and stranding rationale currently
attached to `awaiting_pair_success` onto the `live_flow_remaining` method,
preserving its explanation of the `PairCodeUtils::code_validity` boundary and
stage-2 behavior. Keep only the pair-success/adv-secret documentation above
`awaiting_pair_success`, and ensure both methods’ rustdoc describes the decision
each method implements.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c09dc710-624c-4db1-a438-298e4e6f680f
📒 Files selected for processing (7)
src/client.rssrc/client/lifecycle.rssrc/handlers/notification/companion_reg.rssrc/handlers/notification/mod.rssrc/pair.rssrc/pair_code.rswacore/src/pair_code.rs
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/pair.rs (4)
716-716: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winUse a valid fictional phone number.
15551234567uses555as the NPA, which violates the repository’s test-data rule. Replace it with a number such as12025550111(202-555-0111).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair.rs` at line 716, Replace the hard-coded phone_jid value in the relevant test data with a valid fictional number, such as 12025550111, avoiding 15551234567 because its NPA violates the repository’s test-data rule.Sources: Coding guidelines, Learnings
184-220: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftMake exhaustion teardown atomic with login completion.
The initial
is_logged_in()check can become stale while awaitingpair_code_state.lock(). A concurrent pair-success may finish during that await, after which this task can still dispatchdisconnected = trueand close the newly logged-in socket. Recheck at teardown and coordinate the decision with the login transition rather than relying on the earlier sample.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair.rs` around lines 184 - 220, Make the QR-exhaustion teardown decision atomic with login completion in the surrounding pairing task. After awaiting pair_code_state.lock(), recheck the current login state and coordinate the final disconnected/close decision with the login transition, so a concurrent successful pairing cannot dispatch disconnected=true or close the newly logged-in socket. Preserve the existing behavior for genuinely logged-out QR-only sessions and outstanding pair-code flows.
634-649: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winMake QR payload tests order-independent.
Concurrent callback delivery makes vector position unreliable.
src/pair.rs#L634-L649: retain the initial payload/ref and match later events by secret/ref instead of usingcodes[0]andcodes[1].src/pair.rs#L682-L698: retain the original ref before refresh and search for the refreshed payload carrying the new secret.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair.rs` around lines 634 - 649, Make the QR payload assertions in src/pair.rs lines 634-649 order-independent by retaining the initial payload/reference and locating later events by secret or reference instead of codes[0] and codes[1]. In src/pair.rs lines 682-698, retain the original reference before refresh and search collected events for the refreshed payload containing the new secret; update both sites while preserving their existing assertions.Source: Learnings
92-98: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winInstall the refresh sender before spawning the rotation task.
A concurrent
companion_reg_refreshcan callrefresh_pairing_qrbeforepairing_qr_refresh_txis assigned, so the signal is lost and the displayed QR can retain a retired ADV secret. Store the channels first, then spawn the task; also retire any previous rotation sender when replacing it.Proposed ordering
+ *client.pairing_cancellation_tx.lock().await = Some(stop_tx); + *client.pairing_qr_refresh_tx.lock().await = Some(refresh_tx); + client .runtime .spawn(Box::pin(async move { // ... })) .detach(); - *client.pairing_cancellation_tx.lock().await = Some(stop_tx); - *client.pairing_qr_refresh_tx.lock().await = Some(refresh_tx);Also applies to: 224-225
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair.rs` around lines 92 - 98, Update the pairing QR refresh setup around refresh_tx and the spawned rotation task so pairing_qr_refresh_tx is installed before spawning the task, allowing concurrent refresh_pairing_qr calls from companion_reg_refresh to signal it. When replacing the sender, retire or close the previous rotation sender before storing the new one, then spawn the task using the installed channels.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/pair.rs`:
- Line 716: Replace the hard-coded phone_jid value in the relevant test data
with a valid fictional number, such as 12025550111, avoiding 15551234567 because
its NPA violates the repository’s test-data rule.
- Around line 184-220: Make the QR-exhaustion teardown decision atomic with
login completion in the surrounding pairing task. After awaiting
pair_code_state.lock(), recheck the current login state and coordinate the final
disconnected/close decision with the login transition, so a concurrent
successful pairing cannot dispatch disconnected=true or close the newly
logged-in socket. Preserve the existing behavior for genuinely logged-out
QR-only sessions and outstanding pair-code flows.
- Around line 634-649: Make the QR payload assertions in src/pair.rs lines
634-649 order-independent by retaining the initial payload/reference and
locating later events by secret or reference instead of codes[0] and codes[1].
In src/pair.rs lines 682-698, retain the original reference before refresh and
search collected events for the refreshed payload containing the new secret;
update both sites while preserving their existing assertions.
- Around line 92-98: Update the pairing QR refresh setup around refresh_tx and
the spawned rotation task so pairing_qr_refresh_tx is installed before spawning
the task, allowing concurrent refresh_pairing_qr calls from
companion_reg_refresh to signal it. When replacing the sender, retire or close
the previous rotation sender before storing the new one, then spawn the task
using the installed channels.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 6b86a729-62b6-499a-b55d-3c9e6ca2c92b
📒 Files selected for processing (1)
src/pair.rs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e27d01af96
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Seven from review, all in code this branch introduced. The refresh sender was published after the rotation task was spawned, so a rotation arriving in that window found no sender and was dropped — leaving the code on screen keyed to a secret the server had retired. Both channels are published before the spawn. The rotation handler released the state lock between deciding to rotate and writing the new secret. Stage 2 derives its own secret and builds `companion_finish` under that same lock, so a `primary_hello` landing in the gap had its secret overwritten and its pair-success verified against the wrong one. The write happens under the lock that guarded it. A deferred rotation was discarded rather than deferred: if the flow it yielded to then failed, the secret the server retired stayed the one the QR advertised. It is recorded and applied when the flow gives up. `pair-success` retired the pair-code flow before authenticating the identity it carried, so a response arriving after its own flow was written off cancelled the replacement that succeeded it — and then failed verification against the secret that replacement derived. It retires the flow once the crypto checks out. A caller dropping the request future left its claim behind, and an orphaned claim rejects every later request for the rest of the validity window. Claim ownership is released by guard, armed until the flow is installed, and stage 1 rechecks it before reaching the wire. Finally, the connection-scoped reset ran before the generation was retired, leaving a window where a new request could claim the slot against a connection already going away. It runs after the inner teardown, and the rotation loop body is indented to its own block.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0bc7763afe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`cleanup_connection_state` returns early when no lifecycle is installed, so with the `client-lifecycle` feature compiled in but unused the connection-scoped reset never ran and the flow outlived its socket again. That path is the one the workspace test run takes, which is how CI caught what a `-p whatsapp-rust --lib` run could not.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dda93f77e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/pair_code.rs (2)
379-392: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winThis doc comment is describing the wrong function.
"Hand back a claim ... when stage 1 failed" is
release_code_claim.owns_code_claimhands back nothing — it's a read-only predicate. When you inserted the new fn you left the doc stranded on it, and now the one function that actually mutates the slot is undocumented. In a file where the entire thesis is "claims are identified by token, not by timestamp," the docs have to point at the right thing. This is a one-line move.As per coding guidelines: "Place comments explaining the rationale for a decision at the single point where that decision is made."
📝 Proposed fix: move the doc onto the function it describes
- /// Hand back a claim taken by [`Self::pair_with_code`] when stage 1 failed. - /// - /// Identified by its token, so a claim already superseded — by a - /// cancellation, or by the replacement that followed one — is left alone. + /// Whether the slot is still held by *this* request's claim. + /// + /// Rechecked before the wire send and before installing the flow: a + /// cancellation or a replacement landing in between must stop stage 1. async fn owns_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) -> bool { matches!(&*self.pair_code_state.lock().await, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) } + /// Hand back a claim taken by [`Self::pair_with_code`] when stage 1 failed. + /// + /// Identified by its token, so a claim already superseded — by a + /// cancellation, or by the replacement that followed one — is left alone. async fn release_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair_code.rs` around lines 379 - 392, Move the existing claim-release doc comment from owns_code_claim to release_code_claim. Keep owns_code_claim undocumented as the read-only ownership predicate, and place the comment immediately above the function that resets the state to Idle.Source: Coding guidelines
926-937: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winFictional phone numbers in the new pair-code fixtures don't follow the repo convention. All three sites hardcode
15551234567, which puts555in the NPA slot — the convention here is country code1+ a real NPA + the555exchange + a line number in0100–0199(e.g.12025550111). One root cause: the convention wasn't applied when these fixtures were written.
src/pair_code.rs#L926-L937: changephone_jidinset_waitingto12025550111.src/pair_code.rs#L1215-L1220: changephone_numberin theoptions()helper to12025550111, since every pair-code test seeds from it.src/pair.rs#L716-L750: changephone_jidinqr_exhaustion_keeps_the_socket_up_for_a_pending_pair_successto12025550111.Based on learnings: "In Rust test code that uses fictional NANP phone numbers, format numbers as:
1(country code) + a real NPA (not555) + the fictional555exchange + a 4-digit line number between0100and0199. Do not use an NPA of555."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair_code.rs` around lines 926 - 937, Update the fictional NANP numbers at src/pair_code.rs:926-937 in set_waiting, src/pair_code.rs:1215-1220 in options(), and src/pair.rs:716-750 in qr_exhaustion_keeps_the_socket_up_for_a_pending_pair_success to 12025550111, preserving the existing fixture behavior.Source: Learnings
♻️ Duplicate comments (1)
src/pair.rs (1)
183-188: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winThe match's closing brace is still under-indented —
cargo fmtwill rewrite this.Line 187 closes the
matchopened at line 165 but sits four spaces shallower than it, so theloopandmatchclosers line up at the same column and neither reads as belonging to anything. This was called out on an earlier commit and mostly fixed; this brace got missed. Runcargo fmt --alland it's done — we shouldn't be spending CI cycles on whitespace.As per coding guidelines: "Run
cargo fmt --alland ensure workspace clippy passes withcargo clippy --workspace --all-targets -- -D warningsbefore submission."🧹 Proposed fix
// Same ref and deadline, rebuilt payload. futures::future::Either::Right(( futures::future::Either::Right(_), _, )) => continue, - } + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pair.rs` around lines 183 - 188, Correct the indentation of the closing brace for the match surrounding the futures::future::Either::Right pattern in src/pair.rs, then run cargo fmt --all to apply standard formatting. Verify the workspace with cargo clippy --workspace --all-targets -- -D warnings.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/client/lifecycle.rs`:
- Around line 930-941: Update clear_connection_scoped_pair_code to also reset
the connection-scoped pending_reg_refresh state while holding the appropriate
lock, alongside resetting pair_code_state to Idle. Ensure teardown leaves no
pending registration refresh for the next socket connection.
In `@src/handlers/notification/companion_reg.rs`:
- Around line 71-91: In the companion registration refresh flow, remove the
now-unnecessary bare block surrounding the pair_code_state lock and delete the
explicit drop(state) after rotate_companion_registration. Keep the lock,
awaiting_pair_success handling, comments, and rotation behavior unchanged; let
the guard release naturally at the end of its scope.
In `@src/pair_code.rs`:
- Around line 400-409: Update handle_refresh_code so the path that transitions a
matching WaitingForPhoneConfirmation state to Idle also calls
apply_pending_reg_refresh at the same cleanup point as
start_pair_success_timeout. In cancel_pair_code, do not return early when the
flow is already retired; preserve the cleanup call so any deferred companion
registration refresh is drained.
---
Outside diff comments:
In `@src/pair_code.rs`:
- Around line 379-392: Move the existing claim-release doc comment from
owns_code_claim to release_code_claim. Keep owns_code_claim undocumented as the
read-only ownership predicate, and place the comment immediately above the
function that resets the state to Idle.
- Around line 926-937: Update the fictional NANP numbers at
src/pair_code.rs:926-937 in set_waiting, src/pair_code.rs:1215-1220 in
options(), and src/pair.rs:716-750 in
qr_exhaustion_keeps_the_socket_up_for_a_pending_pair_success to 12025550111,
preserving the existing fixture behavior.
---
Duplicate comments:
In `@src/pair.rs`:
- Around line 183-188: Correct the indentation of the closing brace for the
match surrounding the futures::future::Either::Right pattern in src/pair.rs,
then run cargo fmt --all to apply standard formatting. Verify the workspace with
cargo clippy --workspace --all-targets -- -D warnings.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: da8d1957-33e3-4638-800f-62d2e616c755
📒 Files selected for processing (6)
src/client.rssrc/client/lifecycle.rssrc/handlers/notification/companion_reg.rssrc/handlers/notification/mod.rssrc/pair.rssrc/pair_code.rs
…uing it The queue was a mistake. Replaying a deferred `companion_reg_refresh` correctly means being atomic with pair-success completing, with the flow being retired on any of four paths, and with the connection going away — and review found it wrong on three of those in one round: it rotated without the state lock a pair-success had just been authenticated under, it survived the teardown of the connection that requested it, and `refresh_code` retired the flow without draining it. All of that to recover a QR during the window where the phone-number flow is the one being used. The handler drops the request instead: if that flow fails, the QR advertises a retired secret until the next reconnect, which is where the server asks again. Also removes a `drop(state)` that dropped nothing and the block that no longer scoped anything.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb67af3b22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…n one clock Two from review. A failed stage 1 released its claim only through the guard's `Drop`, which schedules a detached task: the future was already ready by then, so a caller retrying the moment it saw the failure got `CodeAlreadyOutstanding` for a request that had given up. Error paths hand the claim back before returning; `Drop` stays as the backstop for a caller that drops the future instead and never sees the error. The QR ref measured its deadline on the global monotonic clock while sleeping on the runtime's. A `Runtime` with an independent clock — a virtual executor, say — would recompute nearly the full TTL on every re-render and extend the ref past the deadline the server set. One sleep now covers the ref and is polled across re-renders rather than recreated by them, so the runtime's clock alone decides when it is spent; the global clock only feeds the advisory countdown on the event.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52f6126518
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…erifies `EventKind`'s discriminant is what a consumer persists and what indexes the `EventInterest` bitmask, so inserting `PairingQrCodesExhausted` next to its `Event` variant renumbered every kind after it. It goes at the end, where `ServerAck` already sits for the same reason, and the doc now says so instead of claiming declaration order. `handle_pair_success` also stopped the QR rotation before parsing or verifying the response. A pair-success arriving after its own flow was written off would therefore take the displayed code down with it and then be rejected, leaving nothing on screen and no exhaustion event. It stops alongside the state transition, once the identity and HMAC check out.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4024b597ce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
What
The pair-code (
link_code_companion_reg) flow shares a connection with QR rotation but not its lifetime, and four places treated the two as one. Cross-checked against the captured WhatsApp Web bundle —WAWeb/Alt/DeviceLinking{Api,Algorithm,HandleNotification}.js,WAWeb/Handle/PairDevice.js,WAWeb/Handle/CompanionReqRefreshNotification.js,WAWeb/Link/DevicePhoneNumberCodeScreen.react.js.The stage-1/stage-2 crypto was re-audited first and is not at fault: a vector produced by
PairCodeUtilswas replayed through an independent WebCrypto implementation of the primary's half (PBKDF22<<16, AES-CTRlength: 64, HKDF for the bundle key andadv_secret, AES-GCM), which decrypts the key bundle, finds both identity keys in the expected slots, and derives a byte-identicaladv_secret. What was wrong is the flow's lifetime, and the symptom it produces is acompanion_finishthe server accepts (type="result") followed by silence: a primary that cannot open the key bundle reports a failed link to its own user and tells the companion nothing.1. Exhausted QR refs no longer take the connection down with them
Handle/PairDevice.jswaits 60s on the first ref (u = 6e4) and 20s on each of the other five (c = 20 * 1e3) — the same budget we use — and when they run out it cancels its own timer and reportsUNPAIRED_IDLE. It does not close the socket; reloading is left to the surface above (SocketUtils.refreshQR, a user action). EverystopCommscall site in the bundle belongs to a stream error, a failure, or an explicit user action — none to ref exhaustion.We disconnected at 60s + 5×20s = 160s. A pair code is advertised as valid for 180s (
code_validity, matching WA Web'sI = 180), so the teardown revoked a code we had just told the consumer was still good — and anyprimary_hellofor it then arrived at a session the server had already dropped. Request the code on a later ref and the gap is worse: on the sixth, the socket had 20s left against an advertised 180.Now the rotation task reports
Event::PairingQrCodesExhausted { disconnected }and only disconnects when no pair-code flow is outstanding, so QR-only consumers keep the reconnect they rely on to get fresh refs.2. One code at a time
startAltLinkingFlowis guarded byinvariant(stage === Initialized)(Alt/DeviceLinkingApi.js): a secondcompanion_hellois only reachable through an explicitinitializeAltDeviceLinking(). That guard is load-bearing, because a second code does not replace the first for the phone — the server never sees the code and routesprimary_helloby number, so whoever is still reading the older one reaches stage 2 and is answered with a key bundle their code cannot open.We overwrote
pair_code_statesilently, with nothing in the logs to say the displayed code had just been retired.pair_with_codenow fails withPairCodeError::CodeAlreadyOutstanding { remaining }while a code is live, andClient::cancel_pair_code()is the explicit reset — ourinitializeAltDeviceLinking(). Expired codes strand nobody and still let a fresh request through.The module docs now also state the rule this follows from: QR rotation is not a reason to request a new pair code. WA Web mints one per user action and regenerates only on the server's
refresh_code, onforce_manual_refresh, or on its own expiry timers — never on a ref rotating.3. An unanswered
companion_finishnow times outLink/DevicePhoneNumberCodeScreen.react.jsarms1 * MINUTE_MILLISECONDSonprimary_hello_receivedand regenerates the code when it fires (primary_hello_expire), with a 3.25-minute TTL behind it and a cap of 5 regenerations. The primary having read the code says nothing about the outcome, and a primary that failed goes quiet — silence is the only signal there is.We had no timeout at all: after
Sent companion_finish, waiting for pair-successthe flow simply sat there.Event::PairingCodeRefreshnow covers both triggers (the server'srefresh_codeand this timer), and in both cases the flow is cleared before the event fires, so a consumer acting on it is not rejected by the very flow it was told to replace.4.
<notification type="companion_reg_refresh">Handle/CompanionReqRefreshNotification.jsaccepts the stanza with either acompanion_reg_refreshor apair-device-rotate-qrchild, rejects it when neither is present, and re-mints the ADV secret key. We routed it to the catch-all, so the QR we kept advertising carried a secret the server had retired.One deliberate divergence: WA Web rotates unconditionally, but past stage 2 a phone-number flow has already derived the secret the pending pair-success HMAC is computed over. Rotating there converts a link that was about to succeed into one that cannot, so the refresh yields to an outstanding pair code — the QR it is about is what that code is replacing anyway.
5. The ack no longer waits on stage-2 crypto
Alt/DeviceLinkingHandleNotification.jsstartshandlePrimaryHello(i)without awaiting it and returns the ack in the same expression. We awaited the whole of stage 2 — a 131k-round PBKDF2, a DH, a persist and a send — before the notification was acknowledged.Stage 2 moves into a task. The cheap guards (ref, validity window, attempt cap) stay on the notification path so the return value still reflects them, the
pair_code_statelock still serializes derive → persist → send as #976 established, and the task re-checks the ref it was spawned for, so a cancellation or pair-success landing while it waits for the lock stops it.Follow-ups from review
Six races the first push had, all reachable, all in code this branch introduced:
PairCodeState::RequestingCodehanded back when stage 1 fails.companion_finishon the wire for a ref nobody holds. (The first push's description claimed this check was there; it was not.)companion_reg_refreshyielded only to a code inside its validity window. Aprimary_helloaccepted near the end of that window leaves the link pending for up to a minute longer with the adv secret already derived, so the refresh could rotate straight through a pending pair-success.awaiting_pair_successtracks that half of the flow on its own clock.companion_reg_refreshthe power to re-mint the adv secret, every queued payload would have advertised a retired one and a scan would fail verification. Each payload is now built when its ref is published — which is also what WA Web does, since it renders from current state rather than a precomputed list.primaryHelloReceivedAltLinkingfires beforehandlePrimaryHelloInternal), so it is armed on acceptance now.poll_until(.., || true), a no-op that let its negative assertion pass without the timer task ever being polled.Second round
Seven more, from a re-review of the fixes above:
pair_with_codeand the QR-exhaustion guard both read a flow as gone once the 180s window closed, though an acceptedprimary_helloleavescompanion_finishpending for up to a minute more — one would start a second flow racing the pending pair-success, the other would tear down the socket carrying it. Both askis_outstandingnow.companion_reg_refreshhad the opposite error, deferring to a code that was merely displayed. That flow derives its own adv secret when the phone answers, so deferring protected nothing while leaving the QR on the same connection advertising retired material for the whole validity window. It defers only to a pending pair-success — which narrows the divergence from WA Web to the one case that needs it.Link/DeviceQrcode.react.jslistens onadvSecretEventEmitter).companion_hellobelong to a socket the server has dropped; left behind, they made the outstanding-code guard reject the request reconnecting exists to make. Cleared in connection teardown.Not changed, with reason. An authenticated
pair-successis honoured even when the flow that produced it was retired: the HMAC verifying is proof the server finished registering this device, so refusing it would leave us without credentials for a device the server already has — worse than the state it avoids, and not recoverable without a logout.cancel_pair_codewithdraws the local intent; it cannot undo what the phone confirmed. WA Web does not gate this either (Handle/PairSuccess.jschecks a reentrancy flag andisRegistered(), nothing about the linking flow), and once a replacement reaches stage two the rotated secret makes the stale response fail verification anyway.Also unchanged: the pair-success timer waits on the same
pair_code_statelockrun_stage_twoholds from derive through send, so a stage 2 slower than a minute delays it. Releasing that lock earlier would reopen the adv-secret race #976 closed, and the delay is bounded by stage 2 itself — the invariant is worth more than the bound. The pre-wire claim recheck also has no deterministic test: the derivation it races is real CPU work, so the predicate it rechecks is tested instead.Third round
Seven more, and one portability miss of my own:
companion_finishunder that same lock, so aprimary_hellolanding in the gap had its secret overwritten and its pair-success verified against the wrong one.pair-successretired the flow before authenticating it, so a response arriving after its own flow was written off cancelled the replacement that succeeded it — and then failed verification against the secret that replacement derived. It retires the flow once the crypto checks out.And the miss: the re-render loop reached for
tokio::time::sleep_until, which the wasm32 and no-default-features builds do not have — three checks caught it that a native-only local run did not. It tracks elapsed time on the runtime's clock and sleeps through theRuntimetrait, and the two CI build commands are part of the local loop now.Fourth round, and one reversal
companion_reg_refreshthat had to yield to a pending pair-success would still be applied. Review then found it wrong on three counts in one pass: it rotated without the state lock a pair-success had just been authenticated under, it survived the teardown of the connection that requested it, andrefresh_coderetired the flow without draining it. Getting it right means being atomic with pair-success completing, with the flow being retired on any of four paths, and with the connection going away — four synchronisation points to recover a QR during the window where the phone-number flow is the one being used. The handler drops the request instead; if that flow fails, the QR advertises a retired secret until the next reconnect, which is where the server asks again.cleanup_connection_statereturns early when theclient-lifecyclefeature is compiled in but no lifecycle is installed, so the connection-scoped reset never ran on that path — which is the pathcargo test --workspacetakes, and how CI caught what-p whatsapp-rust --libcould not.drop(state)that dropped nothing, and the block that no longer scoped anything.Fifth round
Drop, which schedules a detached task — the future was ready before it ran, so a caller retrying the moment it saw the failure gotCodeAlreadyOutstandingfor a request that had given up. Error paths hand the claim back before returning;Dropstays as the backstop for a caller that drops the future and never sees the error.Runtimewith an independent clock would recompute nearly the full TTL on every re-render and extend the ref past the deadline the server set. One sleep covers the ref and is polled across re-renders rather than recreated by them, so the runtime's clock alone decides when it is spent.Sixth round
EventKind::PairingQrCodesExhaustedmoved to the end of the enum. Its discriminant is what a consumer persists and what indexes theEventInterestbitmask, so inserting it beside itsEventvariant renumbered every kind after it.ServerAckalready sits at the end for the same reason; the doc said "declaration order" and no longer does.handle_pair_successstopped the QR rotation before parsing or verifying the response, so a pair-success arriving after its own flow was written off took the displayed code down with it and was then rejected — nothing on screen, no exhaustion event. It stops alongside the state transition, once the identity and HMAC check out.Breaking changes
EventKindgainsPairingQrCodesExhaustedat the end, so no existing discriminant moves.wacore::pair_code::PairCodeStategainsRequestingCode { code_generation_ts, claim }. Exhaustive matches on it need a new arm; the state is the stage-1 window and is neither a live code nor idle.live_flow_remainingalready folds it in for callers that only ask whether a flow is in progress.pair_with_codenow fails withPairCodeError::CodeAlreadyOutstandingwhere it used to silently replace the outstanding code, and withPairCodeError::Cancelledwhen the flow is withdrawn while stage 1 is in flight. Callers that re-request on a schedule needcancel_pair_code()first — or, better, to stop re-requesting on a schedule.Testing
Written test-first; each fix landed against a failing test.
cancel_pair_codeclears the way; an expired code does not block; a serverrefresh_codeclears the flow it asks to replacelive_flow_remaining(wacore)primary_helloasks for a new code and leaves the state ready for it; a completed pairing silences the timercompanion_reg_refreshcompanion_finishreaches the transportcompanion_reg_refreshwaits for it even after the code expired; the timeout is armed even when stage 2 cannot runClock-driven tests run under
tokio::time::pauseand only step the clock once the timer they are testing has had a turn to arm itself — a jump taken earlier just moves the deadline out of reach. Nothing waits on real time (~2s for the pair suite, stable over 10 consecutive runs). The existing stage-2 tests now poll for the adv rotation the spawned task performs.cargo test --workspace --exclude e2e-testsgreen (the command CI runs, and the one whose feature unification exposed the teardown path above),cargo check --workspace --all-targets,--no-default-features, and the wasm32 release build clean,cargo clippy -p whatsapp-rust -p wacore --all-targets -- -D warningsclean,cargo fmt --allapplied.Not in scope
No change to the pair-code crypto, the QR pairing crypto, or the pair-success path. WA Web's 3.25-minute code TTL and 5-regeneration cap live in its React screen rather than its protocol layer, and are left to the consumer; the one-minute post-
primary_hellotimer is included because nothing above this layer can observe what it reacts to. No QPL markers or telemetry.Semver Checksis red here and onmainalike, with identical counts (30/2/10 failures across the three checked crates,enum_variant_added3 on both) — baseline drift against the last published release, nothing this branch adds. ThePairCodeStatevariant is still a breaking change in Rust terms and is called out above regardless of what the lint counts.