Skip to content

feat(bot): configurable initial app-state key-share wait - #972

Closed
gergesh wants to merge 1 commit into
oxidezap:mainfrom
gergesh:feat/configurable-keepalive-and-appstate-key-wait
Closed

gergesh wants to merge 1 commit into
oxidezap:mainfrom
gergesh:feat/configurable-keepalive-and-appstate-key-wait

Conversation

@gergesh

@gergesh gergesh commented Jul 3, 2026

Copy link
Copy Markdown

What

Makes the initial critical app-state sync's wait for the app-state key-share configurable, defaulting to the current hard-coded 5s.

  • BotBuilder::with_app_state_key_wait(Duration)
  • Client::set_app_state_key_wait(Duration) / Client::app_state_key_wait()

Why

On a fresh pairing, run_initial_sync (node_io.rs) waits up to a hard-coded Duration::from_secs(5) for initial_keys_synced_notifier before it proceeds to fetch the critical (critical_unblock_low etc.) app-state snapshot. The key-share is an encrypted peer message; when a large concurrent history sync is saturating the stream at pairing time, it can arrive after that 5s window. The snapshot is then fetched before the key is stored, and critical sync fails with didn't find app state key (and the follow-up patch snapshot MAC mismatch). For a companion client that reconstructs the address book from critical_unblock_low, that means the account's saved contact names never sync.

listen() still returns the instant the keys arrive, so a larger value only extends the worst case (no key-share) — it doesn't slow down a healthy pairing. A consumer with a deliberately deep history-sync window can raise this to, say, 30s to let the key-share win the race.

How

Stored as an AtomicU64 (ms) on Client, initialized to 5_000, read where the fixed 5s was used. Wired through the builder exactly like the existing wanted_pre_key_count / resend_rate_limit tuning. Default is unchanged, so this is fully backward-compatible.

Tests

  • test_bot_builder_app_state_key_wait — override applies to the client
  • test_bot_builder_default_app_state_key_wait — default is 5s

cargo fmt --all, cargo clippy -p whatsapp-rust --lib --tests, and the new tests all pass.

The initial critical app-state sync waits a fixed 5s for the encrypted
app-state key-share before attempting the snapshot fetch. On a fresh
pairing where a large concurrent history sync competes for the stream,
the key-share can land after that window, so the snapshot is fetched
before the key is stored and critical sync fails with "didn't find app
state key" — dropping the account's saved contact names.

Make the wait configurable via BotBuilder::with_app_state_key_wait /
Client::set_app_state_key_wait, defaulting to the existing 5s so behavior
is unchanged unless opted in. Mirrors the existing wanted_pre_key_count /
resend_rate_limit builder-to-client tuning pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b6742bd8-2ecf-4007-ac3f-5cb9f719d662

📥 Commits

Reviewing files that changed from the base of the PR and between 16298aa and 7746e55.

📒 Files selected for processing (5)
  • src/bot.rs
  • src/client.rs
  • src/client/accessors.rs
  • src/client/lifecycle.rs
  • src/client/node_io.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a configurable wait time for the initial encrypted app-state key sync.
    • The app now supports customizing this timeout while keeping a default of 5 seconds.
  • Bug Fixes

    • The initial sync flow now uses the configured wait duration instead of a fixed timeout, improving behavior for slower connections or delayed key delivery.

Walkthrough

Adds a configurable timeout for the initial critical app-state sync wait for an encrypted app-state key-share. Client gains an atomic field with a 5s default and accessor methods; BotBuilder gains an optional override field, a setter, and application during graph build; handle_success uses the dynamic wait instead of a hardcoded 5-second timeout.

Changes

App-State Key Wait Configuration

Layer / File(s) Summary
Client storage and accessors
src/client.rs, src/client/lifecycle.rs, src/client/accessors.rs
Adds app_state_key_wait_ms: AtomicU64 field to Client, defaults it to 5000ms on construction, and adds set_app_state_key_wait/app_state_key_wait accessor methods.
BotBuilder configuration and tests
src/bot.rs
Adds app_state_key_wait: Option<Duration> field preserved across typestate transitions, a with_app_state_key_wait setter, applies the override via client.set_app_state_key_wait during build_graph, and adds tests for custom and default wait values.
Dynamic wait usage
src/client/node_io.rs
Replaces the hardcoded 5-second timeout in handle_success with a dynamic value from app_state_key_wait(), updating the debug log accordingly.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BotBuilder
  participant Client
  participant handle_success

  BotBuilder->>Client: set_app_state_key_wait(wait)
  Client->>Client: store app_state_key_wait_ms
  handle_success->>Client: app_state_key_wait()
  Client-->>handle_success: key_wait Duration
  handle_success->>handle_success: rt_timeout(key_wait)
Loading

Look, this is a small but important change, and I need it done right — timeouts matter, ok? We're giving people control over how long we wait for the encrypted app-state key-share instead of hardcoding 5 seconds like it's 2004. Clean, tested, wired end-to-end. This is the kind of precision I expect. Ship it.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making the initial app-state key-share wait configurable.
Description check ✅ Passed The description matches the implemented builder/client API, default behavior, motivation, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes the initial critical app-state sync's key-share wait configurable via BotBuilder::with_app_state_key_wait(Duration) and Client::set_app_state_key_wait(Duration), defaulting to the existing hard-coded 5 s so there is no behavior change for existing consumers.

  • src/client.rs / src/client/lifecycle.rs: Adds app_state_key_wait_ms: AtomicU64 initialized to 5_000, mirroring the pattern of wanted_pre_key_count.
  • src/client/accessors.rs: Exposes set_app_state_key_wait / app_state_key_wait accessors using Ordering::Relaxed, consistent with the other tuning accessors.
  • src/bot.rs: Wires the value through BotBuilder with two tests covering the default and a custom override.

Confidence Score: 4/5

Safe to merge — the default is unchanged and the new code path only activates when the caller explicitly sets a custom wait.

The change is well-scoped and fully backward-compatible. Both findings are minor style issues with no effect on runtime correctness.

No files require special attention; both style findings are in src/client.rs and src/client/accessors.rs.

Important Files Changed

Filename Overview
src/client/accessors.rs Adds set_app_state_key_wait/app_state_key_wait accessors; as u64 cast from u128 is safe in practice but loses Clippy truncation guard.
src/client.rs Adds app_state_key_wait_ms: AtomicU64 field; field-level doc is more verbose than the project style guide mandates.
src/client/lifecycle.rs Initializes app_state_key_wait_ms to 5_000 in the constructor, matching the existing hard-coded default.
src/client/node_io.rs Replaces the hard-coded 5 s timeout with the runtime-read app_state_key_wait() value; log message updated to match.
src/bot.rs Adds with_app_state_key_wait builder method, wires it through the constructor, and adds two tests covering both the custom and the default value.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant BotBuilder
    participant Client
    participant node_io as run_initial_sync

    Caller->>BotBuilder: with_app_state_key_wait(Duration)
    BotBuilder->>BotBuilder: "store Option<Duration>"
    BotBuilder->>Client: "build() -> set_app_state_key_wait(wait)"
    Client->>Client: app_state_key_wait_ms.store(ms, Relaxed)

    note over Client,node_io: On connect / initial sync
    node_io->>Client: app_state_key_wait()
    Client-->>node_io: Duration (from AtomicU64 ms)
    node_io->>node_io: rt_timeout(key_wait, keys_notifier.listen())
    alt keys arrive within key_wait
        node_io->>node_io: proceed with keys
    else timeout
        node_io->>node_io: proceed without keys (may fail critical sync)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant BotBuilder
    participant Client
    participant node_io as run_initial_sync

    Caller->>BotBuilder: with_app_state_key_wait(Duration)
    BotBuilder->>BotBuilder: "store Option<Duration>"
    BotBuilder->>Client: "build() -> set_app_state_key_wait(wait)"
    Client->>Client: app_state_key_wait_ms.store(ms, Relaxed)

    note over Client,node_io: On connect / initial sync
    node_io->>Client: app_state_key_wait()
    Client-->>node_io: Duration (from AtomicU64 ms)
    node_io->>node_io: rt_timeout(key_wait, keys_notifier.listen())
    alt keys arrive within key_wait
        node_io->>node_io: proceed with keys
    else timeout
        node_io->>node_io: proceed without keys (may fail critical sync)
    end
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/client/accessors.rs:76-77
`wait.as_millis()` returns `u128`, and `as u64` silently truncates values beyond `u64::MAX` milliseconds. While no realistic timeout would overflow (u64::MAX ms ≈ 584 million years), the cast defeats Clippy's `cast_possible_truncation` pedantic lint and is inconsistent with the codebase's general avoidance of lossy casts. A saturating conversion makes the intent explicit.

```suggestion
        self.app_state_key_wait_ms
            .store(u64::try_from(wait.as_millis()).unwrap_or(u64::MAX), Ordering::Relaxed);
```

### Issue 2 of 2
src/client.rs:795-801
The field doc repeats everything already covered in the public-API doc comments (`set_app_state_key_wait`, `app_state_key_wait`, and the builder method), and the AGENTS.md rule says to be concise and explain *why*, not *what*. Private/crate-visible fields should carry only the "why" that isn't apparent from the name or public API.

```suggestion
    /// Milliseconds; see [`Client::set_app_state_key_wait`]. Raising this lets the
    /// key-share win the race against a heavy history sync at pairing time.
    pub(crate) app_state_key_wait_ms: AtomicU64,
```

Reviews (1): Last reviewed commit: "feat(bot): configurable initial app-stat..." | Re-trigger Greptile

Comment thread src/client/accessors.rs
Comment on lines +76 to +77
self.app_state_key_wait_ms
.store(wait.as_millis() as u64, Ordering::Relaxed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 wait.as_millis() returns u128, and as u64 silently truncates values beyond u64::MAX milliseconds. While no realistic timeout would overflow (u64::MAX ms ≈ 584 million years), the cast defeats Clippy's cast_possible_truncation pedantic lint and is inconsistent with the codebase's general avoidance of lossy casts. A saturating conversion makes the intent explicit.

Suggested change
self.app_state_key_wait_ms
.store(wait.as_millis() as u64, Ordering::Relaxed);
self.app_state_key_wait_ms
.store(u64::try_from(wait.as_millis()).unwrap_or(u64::MAX), Ordering::Relaxed);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/client/accessors.rs
Line: 76-77

Comment:
`wait.as_millis()` returns `u128`, and `as u64` silently truncates values beyond `u64::MAX` milliseconds. While no realistic timeout would overflow (u64::MAX ms ≈ 584 million years), the cast defeats Clippy's `cast_possible_truncation` pedantic lint and is inconsistent with the codebase's general avoidance of lossy casts. A saturating conversion makes the intent explicit.

```suggestion
        self.app_state_key_wait_ms
            .store(u64::try_from(wait.as_millis()).unwrap_or(u64::MAX), Ordering::Relaxed);
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment thread src/client.rs
Comment on lines +795 to +801
/// How long the initial critical app-state sync waits for the encrypted
/// app-state key-share before attempting the snapshot fetch. Default 5s; set
/// via [`BotBuilder::with_app_state_key_wait`] or [`Client::set_app_state_key_wait`].
/// Raise it when a large concurrent history sync can delay the key-share past
/// the default, which otherwise fails critical sync with "didn't find app
/// state key" and drops the account's saved contact names.
pub(crate) app_state_key_wait_ms: AtomicU64,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The field doc repeats everything already covered in the public-API doc comments (set_app_state_key_wait, app_state_key_wait, and the builder method), and the AGENTS.md rule says to be concise and explain why, not what. Private/crate-visible fields should carry only the "why" that isn't apparent from the name or public API.

Suggested change
/// How long the initial critical app-state sync waits for the encrypted
/// app-state key-share before attempting the snapshot fetch. Default 5s; set
/// via [`BotBuilder::with_app_state_key_wait`] or [`Client::set_app_state_key_wait`].
/// Raise it when a large concurrent history sync can delay the key-share past
/// the default, which otherwise fails critical sync with "didn't find app
/// state key" and drops the account's saved contact names.
pub(crate) app_state_key_wait_ms: AtomicU64,
/// Milliseconds; see [`Client::set_app_state_key_wait`]. Raising this lets the
/// key-share win the race against a heavy history sync at pairing time.
pub(crate) app_state_key_wait_ms: AtomicU64,

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/client.rs
Line: 795-801

Comment:
The field doc repeats everything already covered in the public-API doc comments (`set_app_state_key_wait`, `app_state_key_wait`, and the builder method), and the AGENTS.md rule says to be concise and explain *why*, not *what*. Private/crate-visible fields should carry only the "why" that isn't apparent from the name or public API.

```suggestion
    /// Milliseconds; see [`Client::set_app_state_key_wait`]. Raising this lets the
    /// key-share win the race against a heavy history sync at pairing time.
    pub(crate) app_state_key_wait_ms: AtomicU64,
```

**Context Used:** AGENTS.md ([source](https://app.greptile.com/oxidezap/github/oxidezap/whatsapp-rust/-/custom-context?memory=26029e85-0dae-44f2-ab23-b8de43e5e9c7))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Confidence score: 5/5

  • In src/client/accessors.rs, casting wait.as_millis() (u128) directly to u64 can silently truncate extremely large durations, which could make timeout/backoff behavior unexpectedly shorter if those edge values are ever hit; this looks low-risk in normal usage but worth tightening before merge—switch to a checked or saturating conversion so overflow handling is explicit.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/client/accessors.rs">

<violation number="1" location="src/client/accessors.rs:77">
P3: `wait.as_millis()` is `u128`, so the direct `as u64` cast can silently truncate very large durations. A checked/saturating conversion makes the bound explicit and avoids a lossy cast.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/client/accessors.rs
/// initial sync starts, so set it before connecting.
pub fn set_app_state_key_wait(&self, wait: Duration) {
self.app_state_key_wait_ms
.store(wait.as_millis() as u64, Ordering::Relaxed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: wait.as_millis() is u128, so the direct as u64 cast can silently truncate very large durations. A checked/saturating conversion makes the bound explicit and avoids a lossy cast.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/accessors.rs, line 77:

<comment>`wait.as_millis()` is `u128`, so the direct `as u64` cast can silently truncate very large durations. A checked/saturating conversion makes the bound explicit and avoids a lossy cast.</comment>

<file context>
@@ -69,6 +69,19 @@ impl Client {
+    /// initial sync starts, so set it before connecting.
+    pub fn set_app_state_key_wait(&self, wait: Duration) {
+        self.app_state_key_wait_ms
+            .store(wait.as_millis() as u64, Ordering::Relaxed);
+    }
+
</file context>
Suggested change
.store(wait.as_millis() as u64, Ordering::Relaxed);
.store(u64::try_from(wait.as_millis()).unwrap_or(u64::MAX), Ordering::Relaxed);

@jlucaso1

jlucaso1 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Hi @gergesh I'll investigate that, but I'm unable to reproduce this locally. Can you provide logs with debug mode and with PII? Also what version or commit hash of the library are you trying?

@jlucaso1

jlucaso1 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Also, can you check if this another solution works for you? #974

@jlucaso1 jlucaso1 closed this Jul 7, 2026
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