Skip to content

fix(appstate): report what a batched sync actually achieved - #1207

Merged
jlucaso1 merged 20 commits into
mainfrom
fix/appstate-truthful-sync-outcome
Aug 4, 2026
Merged

jlucaso1 merged 20 commits into
mainfrom
fix/appstate-truthful-sync-outcome

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #1203, #1205 and #1206, closing the gaps they left and the ones #1204 surfaced but did not fix.

The false success

sync_collections_batched returned Ok(()) in cases where a collection had not synced:

  • a per-collection CollectionSyncError::Fatal (400/404), logged and skipped
  • MAX_ITERATIONS exhausted with patches still outstanding
  • every collection filtered out by the in-flight dedup
  • a response that omits a requested <collection>, which parses fine and lands nowhere

The initial bootstrap reads that Ok as permission to store critical_sync_done, abort the retry watchdog and dispatch_connected(). A critical_block failing any of those ways left the session connected without the settings it carries, setting_pushName included, and with nothing scheduled to retry.

#1204 proposed turning them into Err. That is worse: the bootstrap returns before needs_initial_full_sync is cleared, so a 400/404 reconnects into the same state every 180s, forever, and since needs_pushname_from_sync derives from the persisted push name it survives a restart.

What this does instead

sync_collections_batched returns a BatchedSyncOutcome with four buckets (synced, fatal, retryable, skipped) and each caller decides what a partial result means. Err is reserved for genuinely global failures.

The buckets are not interchangeable. skipped means an equivalent sync already holds the collection and is doing this work — the only case a caller may ignore. Everything else is a real miss.

Bootstrap outcome Action
all synced cancel watchdog, dispatch Connected
any fatal stop retrying it, connect degraded, emit AppStateSyncFailed, hand the batch's recoverable misses to the background sync
retryable / skipped only keep the watchdog armed, reconnect and retry

Everything else goes through report_background_sync, which logs, emits the event, and schedules a spaced retry.

Why degraded rather than logout or retry

  • WA Web (WAWebSyncdFatal): waits 5s, sends AppStateFatalExceptionNotification to the primary, then socketLogout(SyncdFailure). It can — it owns the re-link UI.
  • whatsmeow (appstate.go): returns the error to the caller. Exposes BuildFatalAppStateExceptionNotification and BuildAppStateRecoveryRequest as builders and never calls them; the first one's doc warns it logs out every linked device.
  • zapo (src/appstate/): classifies into the same states and returns the outcome, no automatic destructive action.

Two of three keep the policy out of the library. Ending a consumer's session is not ours to decide, and node_io.rs:1296 already recorded that choice.

Recovery via COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY is not a route here — WA Web gates it off for exactly the collection that matters:

if (t === CollectionName.CriticalBlock)
  return { shouldPerformRecovery: !1, reason: RECOVERY_STATUS_ENUM.COLLECTION_UNSUPPORTED };

Two bugs from re-reading WAWebSyncdServerSync

ErrorRetry must not be refetched in the same run. WA Web routes ErrorRetry, ErrorFatal and Blocked to doneCollections; only ConflictHasMore, SuccessHasMore and a conflict with pending mutations go to refetchCollections. We pushed retryable errors back into the refetch list, which the corrected cap would have turned into 500 attempts at a collection that had just failed.

The cap is 500, not 5. The loop is (l < y || (i.length > 0 && l < C)) with y = 5, C = 500; the body only runs while something is left to refetch, so it collapses to l < 500. Rounds are back-to-back there too — the syncd backoff spaces retries of a failed collection, not pages of one still making progress.

Taking ErrorRetry out of the loop matches WA Web, but WA Web hands it to a retry state machine afterwards and we had none, so on its own that change would strand a collection after a single 500. schedule_app_state_retry is that machine, with WA Web's spacing (1s, doubling, capped at an hour). The persisted two-day expiry is not here; it needs a store column.

The redesign

Nine review rounds on this branch were almost entirely one class of mistake: work that survives an await, and the connection or the deadline moving underneath it. Generation was read ad hoc in ten places, the deadline checked in three, and the shared bootstrap gate written from five — each caller responsible for remembering. Two forgot, in opposite directions, and several of the fixes introduced the next round's bug.

SyncScope carries the connection a piece of work belongs to and the clock it runs against. Client::admits is the only place either question is asked, and it says why: Retired and Expired are not interchangeable — a retired scope must not write or publish, an expired one on a live connection still may, which is exactly when the bootstrap has to stay armed.

settle_bootstrap does the same for the shared gate. There are now no direct writes to needs_initial_full_sync outside it.

The apply boundary is per collection. process_patch_lists persists each list as it goes, so a batch admitted at the start could still be writing its third collection well outside the scope. process_one_patch_list (a pure extraction in wacore, no behaviour change) lets the client stop between collections, capping what a lost scope can commit at one instead of the whole batch.

Ten ad-hoc generation reads and three deadline checks became two reads inside the scope machinery, three explicit rebinds, and one predicate.

Work that must outlive a planned reconnect rebinds instead of being cancelled — nothing re-issues a dirty-bit or server_sync request once its trigger is consumed. What does not carry over is authority: a rebound run can no longer settle the bootstrap it was scheduled by. And a retry holds through the reconnect window rather than attempting inside it, because process_app_state_sync_task answers Ok(()) at its own shutdown guard without contacting the server, and reading that as success would drop the request silently.

Reservations

SyncInFlight records whether a sync or a patch send holds a collection. Skipping is only sound behind an equivalent sync; the batched path was skipping behind patch sends, which take the same reservation and never fetch, so the sync was dropped and the patches that prompted it went unfetched.

The bootstrap never skips: it has to know a collection is synced before dispatching Connected, and "someone else is trying" is not that. Waits are bounded, because the worker's intake loop runs non-history tasks inline. A wait that runs out is retryable, not skipped — nobody is covering that collection.

Push name

History sync's - placeholder no longer becomes our push name. Storing it makes push_name non-empty for good, so needs_pushname_from_sync goes false, the bootstrap stops looking for the real name, and the account re-announces - on every reconnect. whatsmeow skips the same value in two places. The authoritative setting_pushName mutation has its own path and does not come through here, so an account genuinely named - still gets it.

Found by reading the diff whole, not by review

  • schedule_app_state_task_retry carried schedule_app_state_retry's doc comment while the latter had no summary line.
  • Three rationale blocks had accumulated superseded copies of themselves, documenting one branch with two contradictory explanations.
  • One doc still promised retries never fire against a new socket, which stopped being true when they started rebinding.

Tests

Each verified to fail without its fix rather than merely pass with it. The scope invariants:

  • a_scope_stops_admitting_when_its_connection_or_clock_goes — and that retired outranks having time left
  • a_retired_scope_cannot_move_the_bootstrap_gate — in both directions
  • an_expired_scope_may_still_arm_the_gate
  • rebinding_reports_whether_the_connection_moved
  • only_a_deadline_marks_the_bootstrap
  • a_planned_reconnect_is_not_a_completed_sync

The outcome and request-set behaviour:

  • a_refused_collection_is_reported_fatal_not_synced
  • a_retryable_collection_is_not_refetched_in_the_same_run — asserts exactly one IQ reaches the wire
  • collections_held_by_another_sync_are_reported_skipped — and nothing reaches the wire
  • a_patch_send_holder_is_waited_out_not_skipped
  • a_collection_the_response_omits_is_not_reported_synced
  • an_empty_response_leaves_every_collection_unsynced
  • a_duplicate_collection_is_dropped_before_it_is_applied
  • a_collection_requested_twice_is_reserved_once
  • an_unrequested_collection_in_the_response_is_dropped
  • an_expired_deadline_stops_the_batch_before_it_reserves
  • an_outcome_from_a_retired_connection_is_not_published
  • only_a_fully_synced_outcome_settles_the_bootstrap
  • the_absent_pushname_sentinel_is_not_a_name / a_name_containing_a_dash_is_kept

Two things are deliberately unasserted and say so in the code: that the scheduler refuses to run for a retired generation (two probes both passed with the guard removed — the wrongly-attempted sync fails anyway on a socketless client), and the requeue of a top-level batch failure (the observable is a detached task that sleeps first). A test that passes without its fix is worse than none.

Also not covered: the bootstrap's own branch, which needs a full connection to drive. The outcome it reads is covered; the branch on it is not.

Verification

cargo test -p wacore --lib (1340), cargo test -p whatsapp-rust --lib (1346), cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all --check — all clean.

Left for later

The persisted finite-retry expiry. WA Web keeps a finiteFailureStartTime per collection and promotes to fatal after two days of continuous failure. That needs a store schema change, so the retry here is per-connection only.

The batched sync answered `Ok(())` in three cases where a collection had
not synced: a per-collection fatal error, the iteration cap running out,
and every collection being filtered by the in-flight dedup. The initial
bootstrap reads that `Ok` as permission to cancel its retry watchdog and
dispatch Connected, so a `critical_block` that failed any of those ways
left the session connected without the settings it carries, push name
included, and with nothing scheduled to retry.

Turning them into `Err` is not the fix: the bootstrap never clears
`needs_initial_full_sync` on that path, so a 400/404 would reconnect into
the same state every 180s, and `needs_pushname_from_sync` is derived from
the persisted push name, so it would survive a restart too.

So the call now reports a per-collection outcome and each caller decides.
The bootstrap connects degraded on a refusal and emits AppStateSyncFailed
instead of looping; WA Web notifies the primary and logs out there, which
a library must not do on a consumer's behalf, so it hands over the facts
and keeps the session. Retryable and skipped keep the watchdog armed.

Also from re-reading WAWebSyncdServerSync:

- ErrorRetry belongs in doneCollections, never refetchCollections. We
  refetched it inside the same loop, which the cap below would have
  turned into 500 attempts at a collection that just failed.
- The cap is 500, not 5. The loop reads `(l < y || (i.length > 0 && l <
  C))` with y = 5 and C = 500, and since the body only runs while there
  is something to refetch, that collapses to `l < 500`. Rounds are
  back-to-back there too; the syncd backoff spaces retries of a failed
  collection, not pages of one still making progress.

SyncInFlight now records whether a sync or a patch send holds a
reservation. Skipping is only sound behind an equivalent sync, and the
batched path was skipping behind patch sends, which never fetch. Waits
are bounded, because the sync worker's intake loop runs non-history
tasks inline and a wedged holder would stall everything behind it.

Finally, history sync's `-` placeholder no longer becomes our push name.
Storing it makes push_name non-empty for good, so the bootstrap stops
looking for the real name and the account announces `-` on every
reconnect. The authoritative setting_pushName mutation does not come
through that path, so a genuine `-` still arrives.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added synchronization failure events detailing fatal, retryable, and skipped collections.
    • Background synchronization now reports partial results and connection status.
  • Bug Fixes
    • Improved synchronization handling to avoid duplicate work, wait safely, and retry incomplete operations appropriately.
    • Added safeguards for lengthy synchronization and pagination operations.
    • Corrected pushname handling for absent server values while preserving valid names containing dashes.
  • Tests
    • Added coverage for synchronization outcomes, reservation behavior, key requests, and pushname parsing.

Walkthrough

App-state synchronization now uses typed reservations, bounded waits, retries, and structured collection outcomes. Connection and background handlers report failures through events. History-sync pushname parsing filters the exact absent-name sentinel while preserving dashed names.

Changes

App-state synchronization

Layer / File(s) Summary
Sync reservations and outcome contracts
src/client/app_state.rs, src/request.rs, wacore/src/types/events.rs
Reservations distinguish sync holders from patch sends. Batched sync exposes synced, fatal, retryable, and skipped collections. AppStateSyncFailed carries these results.
Batched sync execution and reservation handling
src/client/app_state.rs
Sync tasks use bounded waits and generation-bound retries. Missing-key, pagination, collection errors, duplicate responses, and skipped reservations receive explicit classifications.
Connection and background outcome propagation
src/client/node_io.rs, src/handlers/ib.rs, src/handlers/notification/groups.rs
Critical sync handles complete, fatal, and retryable outcomes separately. Background sync paths report structured results unless shutdown is active.

History-sync pushname parsing

Layer / File(s) Summary
Pushname sentinel handling
wacore/src/history_sync.rs
Pushname extraction ignores the exact "-" sentinel, retains names such as Jean-Luc, and updates the parity fixture to use a JID-form identifier.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Connection
  participant Client
  participant SyncInFlight
  participant EventBus
  Connection->>Client: sync_collections_batched
  Client->>SyncInFlight: acquire holder-aware reservations
  Client-->>Connection: BatchedSyncOutcome
  alt fatal collections
    Connection->>EventBus: dispatch AppStateSyncFailed
    Connection-->>Connection: continue without refused collections
  else retryable or incomplete collections
    Connection->>EventBus: report sync failure
    Connection-->>Connection: retain reconnect retry
  else all collections synced
    Connection-->>Connection: complete connection setup
  end
Loading

Possibly related PRs

Suggested labels: api-design, breaking-change, size-increase-ok

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Title check ✅ Passed The title clearly summarizes the primary change: reporting accurate outcomes from batched app-state synchronization.
Description check ✅ Passed The description directly explains the batched sync outcome redesign, caller behavior, retry handling, reservations, and related fixes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/appstate-truthful-sync-outcome

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 Aug 4, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR replaces binary app-state batch success with collection-level outcomes and centralizes connection/deadline authority through scoped synchronization and a generation-tagged bootstrap gate.

  • Separates synced, fatal, retryable, and skipped collection outcomes.
  • Adds bounded reservation waiting and spaced retry scheduling.
  • Prevents retired or expired synchronization work from publishing or settling bootstrap state.
  • Pins the new app-state failure event discriminant and rejects the history-sync push-name placeholder.

Confidence Score: 4/5

The PR is not yet safe to merge because a requested app-state sync can still be abandoned after bounded reservation retries without any guaranteed recovery trigger.

The new retry machinery improves timeout classification, but both retry loops remain finite: the task path logs and exits after its round budget, while the batched path delegates remaining work to an unspecified later trigger. A healthy long-lived reservation holder can therefore outlast the waits and leave the requested collection stale.

Files Needing Attention: src/client/app_state.rs

Important Files Changed

Filename Overview
src/client/app_state.rs Introduces scoped admission, collection-level outcomes, typed reservations, bootstrap settlement, and retry scheduling for app-state synchronization.
src/client/node_io.rs Updates critical bootstrap handling so fatal and recoverable partial outcomes receive distinct connection and retry policies.
wacore/src/appstate_sync.rs Extracts per-collection patch-list processing so callers can enforce lifecycle admission between collection applications.
wacore/src/types/events.rs Adds the structured app-state synchronization failure event and pins its append-only event-kind value.
wacore/src/history_sync.rs Prevents the history-sync placeholder value from becoming the persisted push name.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Critical app-state bootstrap] --> B[sync_collections_batched]
  B --> C{Outcome}
  C -->|All synced| D[Settle bootstrap and publish Connected]
  C -->|Fatal present| E[Keep bootstrap armed and connect degraded]
  E --> F[Retry recoverable misses in background]
  C -->|Retryable or skipped only| G[Keep watchdog and bootstrap armed]
  G --> H[Reconnect and retry]
  F --> I{Connection generation changed?}
  I -->|Yes| J[Rebind work and relinquish bootstrap authority]
  I -->|No| K[Report outcome and settle when complete]
Loading

Reviews (19): Last reviewed commit: "fix(appstate): give the gate a tag and s..." | Re-trigger Greptile

Comment thread src/client/node_io.rs
Comment thread src/client/app_state.rs Outdated
Comment thread wacore/src/types/events.rs
@coderabbitai coderabbitai Bot added api-design breaking-change size-increase-ok Accepted binary-size increase: downgrades the per-PR size gate to a warning labels Aug 4, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 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/node_io.rs`:
- Around line 1378-1393: Update the critical app-state sync outcome handling
around dispatch_app_state_sync_failed so the terminal degraded-connect path runs
only when all missing collections are fatal. If outcome.retryable or
outcome.skipped is non-empty, preserve the retry/watchdog flow and do not clear
needs_initial_full_sync; abort the watchdog and dispatch_connected only for an
all-fatal outcome.
🪄 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: 173eaea5-e7ca-4a23-b63a-ef9d84786e6c

📥 Commits

Reviewing files that changed from the base of the PR and between 615682b and a97a859.

📒 Files selected for processing (6)
  • src/client/app_state.rs
  • src/client/node_io.rs
  • src/handlers/ib.rs
  • src/handlers/notification/groups.rs
  • wacore/src/history_sync.rs
  • wacore/src/types/events.rs

Comment thread src/client/node_io.rs
@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 9.95 MiB 9.98 MiB +28.84 KiB (+0.28%) 🔺
bin .text 7.98 MiB 8.00 MiB +27.12 KiB (+0.33%) 🔺
bin allocated (text+data+bss) 9.95 MiB 9.98 MiB +28.31 KiB (+0.28%) 🔺
llvm-lines wacore 513,080 513,151 +71 (+0.01%) 🔺
llvm-lines wacore copies 16,744 16,747 +3 (+0.02%) 🔺
llvm-lines whatsapp-rust lib 727,615 735,546 +7,931 (+1.09%) ⚠️
llvm-lines whatsapp-rust lib copies 22,960 23,156 +196 (+0.85%) 🔺
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.81 MiB 1.83 MiB +24.33 KiB (+1.32%) ⚠️
.text wacore 689.41 KiB 689.41 KiB 0
.text wacore_binary 91.42 KiB 91.42 KiB 0
.text wacore_libsignal 170.64 KiB 170.64 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 21.79 KiB 21.79 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 515.62 KiB 515.62 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 11.83 KiB 11.83 KiB 0
.text std 985.09 KiB 987.79 KiB +2.70 KiB (+0.27%) 🔺
.text other deps 1.90 MiB 1.90 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.81 MiB 1.83 MiB +24.33 KiB (+1.32%)
std 985.09 KiB 987.79 KiB +2.70 KiB (+0.27%)

Baseline: 615682bd4 (latest main run) · Head: ff715819d · Graphs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a97a85995b

ℹ️ 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".

Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs Outdated
Comment thread src/client/node_io.rs Outdated
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs Outdated

@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.

All reported issues were addressed across 6 files

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

Re-trigger cubic

Comment thread src/client/app_state.rs
Comment thread src/client/node_io.rs Outdated
Comment thread src/client/node_io.rs Outdated
Comment thread src/client/app_state.rs
Comment thread wacore/src/types/events.rs
@Salientekill

Copy link
Copy Markdown
Contributor

Ta pouco de review ou quer mais?

Five findings, all real and all verified against the code rather than
taken at face value.

A mixed outcome lost the recoverable collection. The refused arm connects
and falls through to the background spawn, which clears
needs_initial_full_sync, so a critical_block 404 alongside a
critical_unblock_low 503 left the 503 unsynced with nothing to retry it.
Making the arm terminal only when every miss is fatal, as suggested,
brings back the reconnect loop the whole change exists to avoid: the
refusal fails again every time. So the refused path now hands its
recoverable misses to the background sync that already follows.

A retryable collection had no retry at all. Routing ErrorRetry out of the
refetch loop matches WA Web, but WA Web hands it to a retry state machine
afterwards and we had none, so a single 500 left the collection stale
until an unrelated trigger. schedule_app_state_retry is that machine,
with WA Web's spacing (1s, doubling, capped at an hour) and bound to the
connection it was scheduled on. The persisted two-day expiry still isn't
here; that needs a store column.

Outcomes could be published from a retired socket. Both bootstrap arms
dispatched AppStateSyncFailed before checking the generation, so a
consumer following the documented fatal policy could have logged out a
healthy replacement session. Both now check first.

A response that omits a requested collection classified it as nothing at
all, so all_synced() reported a batch that never covered it — the same
false success this change is about. The response is now reconciled
against what was asked for, with duplicates ignored rather than applied
twice.

Two smaller ones: the reservation wait was shorter than a healthy patch
send, which can legitimately run five attempts of DEFAULT_IQ_TIMEOUT, so
it is now derived from those two constants instead of guessed; and
report_background_sync claimed the session was connected when a dirty-bit
sync can run before Connected, so it reads the readiness flag.

A wait that times out is now retryable rather than skipped. Skipped means
an equivalent sync already has the collection, which is the only case a
caller may ignore.
@coderabbitai coderabbitai Bot removed the size-increase-ok Accepted binary-size increase: downgrades the per-PR size gate to a warning label Aug 4, 2026
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 4, 2026

@coderabbitai coderabbitai 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.

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 (3)
src/client/node_io.rs (1)

1456-1461: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep the initial-sync requirement after a global background failure.

sync_collections_batched can return Err for a global failure. report_background_sync only logs that error. Line 1461 then clears needs_initial_full_sync, so a later reconnect does not retry the deferred critical collection.

Clear needs_initial_full_sync only after an Ok(...) outcome. Keep it set when result is Err.

🤖 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/client/node_io.rs` around lines 1456 - 1461, Update the background sync
flow around sync_collections_batched and report_background_sync so
needs_initial_full_sync is cleared only when the sync result is Ok. Preserve the
flag when result is Err, allowing a later reconnect to retry the deferred
critical collection.
src/client/app_state.rs (2)

1540-1544: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the patch-send reservation wait.

SyncInFlightRegistry::begin waits indefinitely. If a sync holder leaks, send_app_state_patch never returns to its caller. This bypasses the reservation timeout added for sync requests.

Wrap this acquisition with rt_timeout(..., APP_STATE_RESERVATION_WAIT, ...). Return an error when the wait expires.

🤖 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/client/app_state.rs` around lines 1540 - 1544, Update the patch-send
acquisition in send_app_state_patch to wrap app_state_syncing.begin(name,
SyncHolder::PatchSend) with rt_timeout using APP_STATE_RESERVATION_WAIT, and
return an error when the timeout expires; preserve the existing Some(name)
handling and successful reservation result.

901-907: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject response collections that were not requested.

process_patch_lists receives every parsed response collection. An unrequested collection has no reservation in guards. The later loop can dispatch its mutations and persist its version concurrently with a sync or patch send for that collection.

Build a requested-name set from pending. Filter or reject unrequested patch_lists before process_patch_lists. Continue to mark requested-but-omitted collections as retryable.

Proposed fix
+            let requested: HashSet<WAPatchName> = pending.iter().copied().collect();
             let mut patch_lists =
                 wacore::appstate::patch_decode::parse_patch_lists_ref(resp.get())?;
+            patch_lists.retain(|list| {
+                if requested.contains(&list.name) {
+                    true
+                } else {
+                    warn!(
+                        target: "Client/AppState",
+                        "Batched sync: response included unrequested collection {:?}",
+                        list.name
+                    );
+                    false
+                }
+            });

Also applies to: 969-977

🤖 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/client/app_state.rs` around lines 901 - 907, Update process_patch_lists
to derive the requested collection-name set from pending and filter or reject
parsed patch_lists entries not present in that set before calling
process_patch_lists. Preserve the existing retryable handling for requested
collections omitted from the response, and apply the same validation to the
related flow around the later referenced logic.
🤖 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/app_state.rs`:
- Around line 706-716: Update the scheduled retry handling around
client.sync_collections_batched so that, after the await, it revalidates the
connection generation and dispatches an AppStateSyncFailed event for
outcome.fatal before returning when no retryable items remain. Preserve the
existing retryable assignment and terminal handling for refusal and skip
outcomes.

---

Outside diff comments:
In `@src/client/app_state.rs`:
- Around line 1540-1544: Update the patch-send acquisition in
send_app_state_patch to wrap app_state_syncing.begin(name,
SyncHolder::PatchSend) with rt_timeout using APP_STATE_RESERVATION_WAIT, and
return an error when the timeout expires; preserve the existing Some(name)
handling and successful reservation result.
- Around line 901-907: Update process_patch_lists to derive the requested
collection-name set from pending and filter or reject parsed patch_lists entries
not present in that set before calling process_patch_lists. Preserve the
existing retryable handling for requested collections omitted from the response,
and apply the same validation to the related flow around the later referenced
logic.

In `@src/client/node_io.rs`:
- Around line 1456-1461: Update the background sync flow around
sync_collections_batched and report_background_sync so needs_initial_full_sync
is cleared only when the sync result is Ok. Preserve the flag when result is
Err, allowing a later reconnect to retry the deferred critical collection.
🪄 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: 9a96608a-dde6-440f-9333-61c0e01366ee

📥 Commits

Reviewing files that changed from the base of the PR and between a97a859 and a778769.

📒 Files selected for processing (4)
  • src/client/app_state.rs
  • src/client/node_io.rs
  • src/request.rs
  • wacore/src/types/events.rs

Comment thread src/client/app_state.rs Outdated

@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.

All reported issues were addressed across 4 files (changes from recent commits).

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

Re-trigger cubic

Comment thread src/client/app_state.rs Outdated
Comment thread src/client/node_io.rs
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs
Comment thread src/request.rs Outdated
Comment thread src/client/app_state.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a778769090

ℹ️ 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".

Comment thread src/client/node_io.rs Outdated
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs Outdated
Comment thread src/client/node_io.rs Outdated
Comment thread wacore/src/types/events.rs Outdated
Seven findings, several raised by more than one reviewer.

Duplicate collections were reconciled too late. The processor persists
every list it is handed, so a repeated <collection> was applied twice
before the bookkeeping guard saw it, and the second application could
move the MAC store past the version the first then wrote back. Duplicates
are now dropped from the parsed lists before anything applies them.

The retry loop swallowed a later refusal. A round that turns a transient
failure into a 400/404 left the consumer holding only the earlier
transient report, which is the opposite of the event's contract. Each
round now publishes a non-synced outcome before deciding whether it is
finished, and re-checks the generation first because it just awaited a
round trip.

The background sync reported unconditionally. It checked the generation
before its await but not before publishing, so a retired socket's outcome
could reach a consumer who would apply its fatal policy to the live
session. It now re-checks after the await, like the critical arms.

needs_initial_full_sync was cleared even when the background run left
something unsynced, which makes the next connection skip the bootstrap
while the retry that would have covered the gap dies with this
generation. It is now cleared only on a complete run.

The refused arm claimed connected: true before Connected was published,
so a disconnect during the presence resubscribe would have made that a
lie. It now reports after the readiness transition and reads the flag.

WaitTimedOut is no longer lost on the single-collection task path, which
dropped the request outright. It goes to the retry scheduler. That also
settles the reservation bound: a holder's honest worst case is not
derivable, since a patch send keeps the reservation across a re-sync that
may page up to MAX_PAGINATION_ITERATIONS, so what makes the bound safe is
that expiring is never lossy rather than the number itself.

Event::AppStateSyncFailed moved to the end of the enum. Event derives
Serialize, and an index-based format keys variants by position, so
inserting it beside its relatives renumbered every later variant.
@greptile-apps
greptile-apps Bot dismissed their stale review August 4, 2026 04:53

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@coderabbitai coderabbitai Bot added the size-increase-ok Accepted binary-size increase: downgrades the per-PR size gate to a warning label Aug 4, 2026
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 4, 2026

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/app_state.rs (1)

1584-1588: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the patch-send reservation wait.

send_app_state_patch holds app_state_send_lock before calling self.app_state_syncing.begin(name, SyncHolder::PatchSend), and that begin() loop has no timeout. A stalled holder will block later patch sends. Wrap the reservation acquisition with rt_timeout(&*self.runtime, APP_STATE_RESERVATION_WAIT, ...), return the timeout as retryable work, and only send the patch after obtaining the reservation. No patch send should proceed without it.

🤖 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/client/app_state.rs` around lines 1584 - 1588, Update
send_app_state_patch around app_state_syncing.begin(name, SyncHolder::PatchSend)
to acquire the reservation through rt_timeout using APP_STATE_RESERVATION_WAIT.
Treat a timeout as retryable work, and ensure the patch-send path proceeds only
after the reservation is successfully obtained.

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.

Outside diff comments:
In `@src/client/app_state.rs`:
- Around line 1584-1588: Update send_app_state_patch around
app_state_syncing.begin(name, SyncHolder::PatchSend) to acquire the reservation
through rt_timeout using APP_STATE_RESERVATION_WAIT. Treat a timeout as
retryable work, and ensure the patch-send path proceeds only after the
reservation is successfully obtained.

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c7ababa6-deab-4242-aca9-fff9d3feca89

📥 Commits

Reviewing files that changed from the base of the PR and between a778769 and ed048f2.

📒 Files selected for processing (3)
  • src/client/app_state.rs
  • src/client/node_io.rs
  • wacore/src/types/events.rs

… buckets

The timeout test only constructed an outcome and asserted bucket
semantics, so it passed whether or not a real timed-out wait landed in
`retryable` — the same false coverage this branch called out elsewhere.
It now holds a patch-send reservation that never releases and drives the
batched sync through the bound, under paused time so it does not sit
there for the full wait. Flipping the classification back to `skipped`
fails it.

Also: DEFAULT_IQ_TIMEOUT's comment claimed a ceiling it does not have,
since both IQ entry points take a caller-supplied timeout. It documents
the default path, which is the basis app-state derives its wait from.
@greptile-apps
greptile-apps Bot dismissed their stale review August 4, 2026 04:57

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 667d22e866

ℹ️ 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".

Comment thread src/handlers/notification/groups.rs Outdated

@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.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/node_io.rs
Comment thread src/client/node_io.rs Outdated
The server_sync handler checked the generation before its sync and not
after, so a connection replaced while blobs or patches were still being
processed would still have its outcome published — and a consumer acting
on a fatal AppStateSyncFailed would apply logout or recovery to the
healthy session that replaced it. The dirty-bit handler had the same
exposure and did not track a generation at all.

Rather than repeat the guard at each call site, report_background_sync
takes the generation the sync started on and drops anything else. Every
caller awaits a round trip before reporting, so the one that forgets is
the one that matters; making it a parameter means a new call site cannot
skip it silently.

The background bootstrap sync keeps its own check for the flag it clears
afterwards, which the report no longer reaches.
@greptile-apps
greptile-apps Bot dismissed their stale review August 4, 2026 13:28

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

Comment thread src/client/app_state.rs Outdated

@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.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/app_state.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c46440502e

ℹ️ 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".

Comment thread src/client/node_io.rs
…error

A reservation wait that ran out consumed a retry round even though it
never reached the server, so a writer holding the collection through the
whole budget could drop the caller's request without the sync being tried
once. Attempts and rounds are counted separately now: only a reservation
actually taken spends an attempt, and the loop is still bounded overall.

The critical sync's Err arm left the bootstrap flag alone. A batch can
fail partway, after critical_block already dispatched and persisted
setting_pushName, and the watchdog's reconnect would then see a populated
push name with a clear flag, take the ordinary path, and never retry the
rest. Armed here as in the incomplete arm beside it.

@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.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/node_io.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52e46b49d4

ℹ️ 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".

Comment thread src/client/app_state.rs Outdated
Comment thread src/client/app_state.rs Outdated
…onnection

is_shutting_down() is true for a planned reconnect as well as a stop, so
a retry waking inside a 515 or reconnect_immediately window returned and
dropped its collections for good — the rebinding branch added for exactly
that case sat below the check and was never reached. Both retry loops now
end only when the client is actually stopping, and the reconnect is
handled by rebinding.

The task retry discarded its request on a generation change. Nothing on
the replacement connection re-issues a consumer's task, and a
full_sync: true one is the snapshot request that scheduler exists to
preserve, so a reconnect was silently losing it. It rebinds too.

Re-arming the bootstrap on a critical-sync error was not generation-aware.
A late error from a socket already replaced would re-arm a gate the
replacement had just stood down, costing it a 180s bootstrap it did not
need.

@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.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/node_io.rs Outdated
Comment thread src/client/app_state.rs Outdated
Comment thread src/client/node_io.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b5c6b3de2

ℹ️ 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".

Comment thread src/client/app_state.rs Outdated
"Retrying app state {pending:?} (round {}/{APP_STATE_RETRY_MAX_ROUNDS})",
round + 1
);
match client.sync_collections_batched(pending.clone(), None).await {

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 Badge Pause background retries until a socket is available

When an ordinary reconnect enters a long backoff, is_running remains true while no transport exists, so this call immediately fails on every round rather than waiting for the replacement socket. The eight delays total only 255 seconds, while src/client/lifecycle.rs permits reconnect backoffs up to 900 seconds; all attempts can therefore be exhausted and pending dropped before reconnection, leaving a consumed dirty-bit or server_sync request stale indefinitely. Fresh evidence beyond the earlier reconnect comment is that the new generation-rebinding logic still does not wait for connection readiness.

Useful? React with 👍 / 👎.

Comment thread src/client/app_state.rs Outdated
Comment thread src/client/app_state.rs Outdated
Comment on lines +960 to +962
client
.needs_initial_full_sync
.store(false, 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 Badge Recheck generation when clearing the bootstrap gate

If another runtime thread retires the connection after the generation check at line 915 but before this store, the old retry can clear needs_initial_full_sync for the replacement generation. In particular, it can overwrite a conservative re-arm made after the replacement's critical sync fails, causing a later reconnect with a populated push name to skip the unfinished bootstrap. Fresh evidence beyond the earlier settlement-race comment is that the analogous node_io.rs settlement now rechecks after clearing, while this newly added retry settlement remains unguarded.

Useful? React with 👍 / 👎.

Every app-state path awaits round trips, and between them the socket can
retire or the bootstrap's watchdog can fire. That was checked by hand at
each boundary, and the checks drifted: some paths grew one the next
forgot, the same load answered two different questions, and the last nine
review rounds were almost entirely this one class of mistake — including
several introduced while fixing others in it.

SyncScope carries the connection the work belongs to and the clock it
runs against. Client::admits is the only place either question is asked,
and it returns why, so callers can tell "this socket is gone" from "this
ran out of time" — a retired scope must not write, an expired one on a
live connection still may.

settle_bootstrap is the same idea for the shared gate. It was written
from five places, each responsible for remembering the generation check;
two forgot, in opposite directions. There are now no direct writes to
needs_initial_full_sync outside it.

The apply boundary is per collection. process_patch_lists persists each
list as it goes, so a batch admitted at the start could still be writing
its third collection well outside the scope. process_one_patch_list
exposes that step so the client can stop between collections, capping
what a lost scope can commit at one instead of the whole batch.

Counts, before and after: ten ad-hoc generation reads and three separate
deadline checks become two reads inside the scope machinery, three
explicit rebinds, and one predicate.

Also fixed while reading the diff whole, none of which any review caught:
schedule_app_state_task_retry carried schedule_app_state_retry's doc
comment while the latter had no summary line; three rationale blocks had
accumulated superseded copies of themselves, documenting one branch with
two contradictory explanations; and one doc still promised that retries
never fire against a new socket, which stopped being true when they
started rebinding.
process_app_state_sync_task answers Ok(()) at its own shutdown guard
without contacting the server, and expected_disconnect makes that guard
true for an ordinary reconnect as well as a stop. Swapping the retry
loop's guard to is_running let it attempt during that window and take the
no-op for a completed sync, dropping the consumer's request — a full_sync
snapshot included — without ever asking for it.

The retry now holds through a planned reconnect instead of spending an
attempt on it. That window is exactly what the rebind below it was added
to survive.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d10dbc6fdd

ℹ️ 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".

Comment thread src/handlers/ib.rs Outdated
Comment thread src/client/node_io.rs
Comment thread src/client/app_state.rs

@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.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/node_io.rs
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs
Six findings, all of the same shape now that the structure is in place:
one more await that had no admission check after it.

The results loop dispatched mutations and wrote versions without
re-asking. process_one_patch_list awaits, so a scope could be lost
between a collection being decoded and its mutations reaching consumers.
Everything decoded so far is persisted by its own call, so stopping
leaves a consistent prefix.

The task retry's reservation wait is an await too, and the reconnect can
start inside it — after which the callee answers Ok(()) at its shutdown
guard and the loop reads a no-op as a completed sync. Re-asked before an
attempt is counted.

settle_bootstrap's check and store are two operations with the generation
moving under a lock it does not hold. It now undoes a write it discovers
was meant for a connection that retired between them, so the live one
keeps the value it had.

The dirty-bit resync rebinds after the clean IQ instead of returning. The
server has already accepted the bit as clean and will not raise it again,
and an ordinary reconnect with a known push name runs no bootstrap, so
returning left those collections stale indefinitely.

The fatal-critical branch arms the gate before publishing its event.
Publishing runs consumer handlers synchronously, and one forcing a
reconnect would retire the task that settles it — leaving the flag false
with the push name already populated, so the replacement skips a
bootstrap it still owes.

A retry sequence that exhausts its budget now says so. Consumers heard
about every round that produced buckets, but a sequence whose rounds all
failed before producing any finished in silence with the collections
still stale.

Also fixes the Rustdoc CI failure: an intra-doc link to a sibling method
needs the Self:: prefix. cargo clippy does not run rustdoc lints, which
is why the local checks were clean — cargo doc is now part of them.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5f669bc73

ℹ️ 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".

Comment thread src/client/app_state.rs Outdated

@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 4 files (changes from recent commits).

Confidence score: 2/5

  • In src/client/app_state.rs, the generation handoff has a race because admission and bootstrap-gate update are split across separate atomics, so a replacement connection can miss bootstrap or have its gate overwritten by an old scope; this can leave client state improperly initialized and cause hard-to-reproduce connection regressions — make the handoff atomic (e.g., single CAS/critical section tied to generation) so only the active scope can set the bootstrap gate.
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/app_state.rs">

<violation number="1" location="src/client/app_state.rs:851">
P1: A replacement connection can skip its initial bootstrap, or have its bootstrap gate clobbered, during a generation handoff. The admission check and gate update are separate atomics, so an old scope can write between the new connection's generation/read checks and the rollback can overwrite a newer value; synchronizing the generation and gate update or storing a generation-tagged gate would make this transition atomic.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/app_state.rs Outdated
Comment thread src/client/app_state.rs Outdated
Comment thread src/handlers/ib.rs
Comment thread src/client/node_io.rs Outdated
Comment thread src/client/node_io.rs Outdated
The post-apply admission check I added last round loses data.
process_one_patch_list persists the collection's version and mutation
MACs before returning, so by the time the results loop runs the cursor
has already advanced. Skipping the dispatch there and marking the
collection retryable means the retry asks from the new version, the
server never resends, and the decoded mutations are gone —
setting_pushName and the NCT salt among them.

I took the suggestion to check later without establishing whether the
callee persisted. It does. The scope is checked before the apply, which
is the last moment a collection can still be declined; after it,
dispatching is not optional. The comment there now says so, since the
next reader will have the same instinct I did.

Three more, all the bootstrap gate losing track of what it owes:

The fatal arm armed the gate after dispatch_connected, with a generation
check between them that returns the whole task — so a Connected handler
forcing a reconnect meant the gate was never armed at all. A refusal
makes the bootstrap unfinished whatever happens next, so it is armed
first, before anything a consumer handler can interrupt.

A refused collection was absent from the background settlement's
accounting: it is not in critical_retry, because retrying it is
pointless, so a clean background run stood the gate down for a collection
that never synced. It is carried explicitly now.

The dirty-bit resync rebound before its batch but not after, so a
reconnect during the sync had the outcome — and the retry with it —
discarded for collections whose dirty bit the server already considers
clean.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 459c5a6655

ℹ️ 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".

Comment thread src/client/app_state.rs Outdated
Comment thread src/handlers/ib.rs
Comment thread src/client/app_state.rs Outdated

@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.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/handlers/ib.rs
Comment thread src/client/node_io.rs
Comment thread src/client/app_state.rs
Comment thread src/client/app_state.rs
Two things had now been patched three times each, at three different
lines, and each patch introduced the next hazard. Both were the same
shape: a value that cannot express what callers need to say.

The bootstrap gate is generation-tagged. A plain flag could not be
settled safely — deciding whether the writer still owns the connection
and writing were two operations, and bridging them by checking first
missed a retirement in the gap while rolling back afterwards clobbered
whatever the replacement had written. The tag makes the write a single
compare-and-swap that only ever moves forward, so an older connection is
refused outright. The admission check stays: the tag stops a stale write
overwriting a newer one, the check stops a retired writer having a say at
all, and neither covers the other's case.

is_shutting_down answered two questions at once — going away for good,
and between connections — and every caller that outlives a connection
wanted only one of them. It kept reading "reconnect" as "stop" and
throwing work away. There are now is_stopping and is_reconnecting, and
the callers that must survive a reconnect use the first.

The remaining four follow from those:

A retry whose attempt was cut short is no longer a completed one.
process_app_state_sync_task breaks its pagination and answers Ok(()) as
soon as it observes a shutdown, so a reconnect starting mid-attempt ended
the scheduler and lost the request, full_sync included.

The dirty-bit resync reports through a planned reconnect instead of being
dropped by it. The server already considers the bit clean and nothing
would ask again.

A refused critical collection is carried into the retry scheduler's
stranded state. It is not in the requested set — retrying it is
pointless — but it is why the bootstrap is unfinished, and a later clean
round would otherwise settle the gate on its behalf.

A run that crossed its deadline no longer reports its collections as
synced. They are applied and persisted, but calling the run clean let the
bootstrap abort its watchdog and dispatch Connected on an expired scope,
which is what the deadline exists to prevent; they go down the retry path
and resume from the versions already stored.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a2c35c9f2

ℹ️ 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".

Comment thread src/client/app_state.rs
Comment on lines +147 to +148
self.0
.store(Self::encode(u64::MAX >> 1, true), Ordering::Release);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow a post-pairing generation to settle the gate

arm_for_pairing stores the largest representable generation tag, but settle rejects every writer whose generation is lower. Consequently, after any fresh pairing, the successful bootstrap's settle_bootstrap(scope, false) cannot clear the gate for any practical connection_generation; every later reconnect is misclassified as an initial sync and repeatedly runs the critical bootstrap, potentially delaying readiness by its 180-second watchdog. Use a pairing marker that the next connection can consume rather than a tag that permanently outranks all connections.

Useful? React with 👍 / 👎.

@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.

4 issues found across 6 files (changes from recent commits).

Confidence score: 3/5

  • In src/client/node_io.rs (settle vs pairing in arm_for_pairing), the stored bootstrap generation can be initialized above any real writer generation, so post-pair connections never satisfy the gate and the client can stay stuck disconnected — align the pairing seed and settle comparison so a valid first connection can progress.
  • In src/client/lifecycle.rs (reconnect()), planned reconnects are not marked with expected_disconnect, so retry logic treats an intentional transport swap as an offline failure and runs against a dead socket — set the reconnect intent flag before teardown so retries remain quarantined during handoff.
  • In src/client/lifecycle.rs (is_running) and src/handlers/ib.rs (is_stopping() path), lifecycle state can flip retry tasks off too early for direct-connect clients, while shutdown/error handling can still emit dirty-sync failure + retry during terminal logout/conflict — tighten state predicates around terminal-stop vs reconnect transitions and gate retry/failure publication on the same terminal flag.
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/node_io.rs">

<violation number="1" location="src/client/node_io.rs:1248">
P2: After a fresh pairing (pair.rs `arm_for_pairing` writes generation `u64::MAX >> 1`), no real connection can ever settle the bootstrap gate, because `settle` refuses any writer whose generation is lower than the stored one. The result is that `is_armed()` here (this changed line) stays true for the life of the session, so every reconnect re-enters the Full App State Sync path: re-fetching all critical collections, re-arming watchdogs and re-dispatching `Connected` even after a fully successful bootstrap. Root cause is in `BootstrapGate::arm_for_pairing` in app_state.rs (deviates from the 'generation zero owns the first write' contract on `new`); consider arming with a low generation the first real connection outranks, e.g. `Self::encode(0, true)`, or otherwise ensuring a post-pairing connection can stand the gate down.</violation>
</file>

<file name="src/handlers/ib.rs">

<violation number="1" location="src/handlers/ib.rs:166">
P2: Terminal logout/conflict handling can still publish a dirty-sync failure and enqueue a retry before `is_running` is cleared, because `is_stopping()` does not reflect the earlier `enable_auto_reconnect = false` transition. The guard should also reject the terminal auto-reconnect-disabled state while continuing to allow planned reconnects.</violation>
</file>

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

<violation number="1" location="src/client/lifecycle.rs:92">
P2: Direct-connect clients are now treated as permanently stopping, so app-state retry tasks cancel even while their socket is usable. `is_running` tracks the supervision loop rather than terminal shutdown; this predicate should use the terminal shutdown signal or a separate terminal-state flag instead.</violation>

<violation number="2" location="src/client/lifecycle.rs:98">
P2: Ordinary planned reconnects are not recognized as reconnecting: `reconnect()` tears down the transport without setting `expected_disconnect`, so app-state retries run against the offline socket instead of remaining queued for the replacement connection. Include the intentional/disconnected state here while excluding terminal shutdown.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/node_io.rs
check_generation!();

let flag_set = client_clone.needs_initial_full_sync.load(Ordering::Relaxed);
let flag_set = client_clone.needs_initial_full_sync.is_armed();

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: After a fresh pairing (pair.rs arm_for_pairing writes generation u64::MAX >> 1), no real connection can ever settle the bootstrap gate, because settle refuses any writer whose generation is lower than the stored one. The result is that is_armed() here (this changed line) stays true for the life of the session, so every reconnect re-enters the Full App State Sync path: re-fetching all critical collections, re-arming watchdogs and re-dispatching Connected even after a fully successful bootstrap. Root cause is in BootstrapGate::arm_for_pairing in app_state.rs (deviates from the 'generation zero owns the first write' contract on new); consider arming with a low generation the first real connection outranks, e.g. Self::encode(0, true), or otherwise ensuring a post-pairing connection can stand the gate down.

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

<comment>After a fresh pairing (pair.rs `arm_for_pairing` writes generation `u64::MAX >> 1`), no real connection can ever settle the bootstrap gate, because `settle` refuses any writer whose generation is lower than the stored one. The result is that `is_armed()` here (this changed line) stays true for the life of the session, so every reconnect re-enters the Full App State Sync path: re-fetching all critical collections, re-arming watchdogs and re-dispatching `Connected` even after a fully successful bootstrap. Root cause is in `BootstrapGate::arm_for_pairing` in app_state.rs (deviates from the 'generation zero owns the first write' contract on `new`); consider arming with a low generation the first real connection outranks, e.g. `Self::encode(0, true)`, or otherwise ensuring a post-pairing connection can stand the gate down.</comment>

<file context>
@@ -1245,7 +1245,7 @@ impl Client {
             check_generation!();
 
-            let flag_set = client_clone.needs_initial_full_sync.load(Ordering::Relaxed);
+            let flag_set = client_clone.needs_initial_full_sync.is_armed();
             let needs_initial_sync = flag_set || needs_pushname_from_sync;
 
</file context>

Comment thread src/handlers/ib.rs
// considers the dirty bit clean, so nothing would
// ask again. The scheduler rebinds once the
// replacement is live.
if !client_clone.is_stopping() {

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: Terminal logout/conflict handling can still publish a dirty-sync failure and enqueue a retry before is_running is cleared, because is_stopping() does not reflect the earlier enable_auto_reconnect = false transition. The guard should also reject the terminal auto-reconnect-disabled state while continuing to allow planned reconnects.

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

<comment>Terminal logout/conflict handling can still publish a dirty-sync failure and enqueue a retry before `is_running` is cleared, because `is_stopping()` does not reflect the earlier `enable_auto_reconnect = false` transition. The guard should also reject the terminal auto-reconnect-disabled state while continuing to allow planned reconnects.</comment>

<file context>
@@ -157,7 +157,13 @@ async fn handle_ib_impl(client: Arc<Client>, node: &wacore_binary::NodeRef<'_>)
+                            // considers the dirty bit clean, so nothing would
+                            // ask again. The scheduler rebinds once the
+                            // replacement is live.
+                            if !client_clone.is_stopping() {
                                 client_clone.report_background_sync(
                                     "app state re-sync after dirty notification",
</file context>
Suggested change
if !client_clone.is_stopping() {
if !client_clone.is_stopping()
&& client_clone
.enable_auto_reconnect
.load(std::sync::atomic::Ordering::Relaxed)
{

Comment thread src/client/lifecycle.rs
/// Whether a planned reconnect is in flight: the client is staying, but the
/// socket is not usable and anything sent now is a no-op.
pub(crate) fn is_reconnecting(&self) -> bool {
self.expected_disconnect.load(Ordering::Relaxed) && !self.is_stopping()

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: Ordinary planned reconnects are not recognized as reconnecting: reconnect() tears down the transport without setting expected_disconnect, so app-state retries run against the offline socket instead of remaining queued for the replacement connection. Include the intentional/disconnected state here while excluding terminal shutdown.

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

<comment>Ordinary planned reconnects are not recognized as reconnecting: `reconnect()` tears down the transport without setting `expected_disconnect`, so app-state retries run against the offline socket instead of remaining queued for the replacement connection. Include the intentional/disconnected state here while excluding terminal shutdown.</comment>

<file context>
@@ -79,6 +79,25 @@ impl Client {
+    /// Whether a planned reconnect is in flight: the client is staying, but the
+    /// socket is not usable and anything sent now is a no-op.
+    pub(crate) fn is_reconnecting(&self) -> bool {
+        self.expected_disconnect.load(Ordering::Relaxed) && !self.is_stopping()
+    }
+
</file context>

Comment thread src/client/lifecycle.rs
/// survive a reconnect want this; callers that must not touch the socket
/// right now want `is_shutting_down`.
pub(crate) fn is_stopping(&self) -> bool {
!self.is_running.load(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: Direct-connect clients are now treated as permanently stopping, so app-state retry tasks cancel even while their socket is usable. is_running tracks the supervision loop rather than terminal shutdown; this predicate should use the terminal shutdown signal or a separate terminal-state flag instead.

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

<comment>Direct-connect clients are now treated as permanently stopping, so app-state retry tasks cancel even while their socket is usable. `is_running` tracks the supervision loop rather than terminal shutdown; this predicate should use the terminal shutdown signal or a separate terminal-state flag instead.</comment>

<file context>
@@ -79,6 +79,25 @@ impl Client {
+    /// survive a reconnect want this; callers that must not touch the socket
+    /// right now want `is_shutting_down`.
+    pub(crate) fn is_stopping(&self) -> bool {
+        !self.is_running.load(Ordering::Relaxed)
+    }
+
</file context>

@jlucaso1
jlucaso1 merged commit 9a78719 into main Aug 4, 2026
30 of 31 checks passed
@jlucaso1
jlucaso1 deleted the fix/appstate-truthful-sync-outcome branch August 4, 2026 16:11
jlucaso1 added a commit to oxidezap/whatsapp-rust-docs that referenced this pull request Aug 4, 2026
…strap (#473)

* docs(appstate): document AppStateSyncFailed and degraded-connect bootstrap

whatsapp-rust#1207 reworked sync_collections_batched to report a
per-collection outcome (synced/fatal/retryable/skipped) instead of a bare
Ok(()), and added Event::AppStateSyncFailed. A fatal critical_block failure
during the pairing bootstrap now connects in a degraded state rather than
looping forever, and retryable collections back off on WhatsApp Web's
schedule instead of being refetched within the same batch.

Ref: oxidezap/whatsapp-rust#1207

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fRFZef6tyPkRzqP2hrsb4

* docs(appstate): fix review findings on AppStateSyncFailed docs

- connected: define independently of the fatal bucket (bootstrap vs.
  background paths set it for different reasons; a purely retryable/skipped
  background sync also reports connected: true, contradicting the previous
  "true only when fatal" wording)
- fatal recovery: a fatal outcome does not retry automatically on the same
  connection (WA Web gates COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY off for
  critical_block, and this client implements no alternative) — replace the
  "later background retry" claim with "needs a fresh connection", and scope
  the backoff-schedule claim to retryable collections only
- add the critical_unblock_low wire name to the collection list (confirmed
  in wacore/appstate/src/schemas.rs)
- push name/presence: note the push name may already be known via
  Bot::with_push_name or an earlier session, so a fatal critical_block
  doesn't unconditionally leave presence unavailable
- syncd IQ error codes (400/404) are IQ-level, not HTTP statuses
- drop "permanently unsynced" from the example log line, which overstated
  fatal's terminality

Addresses review feedback from Codex and CodeRabbit on PR #473.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fRFZef6tyPkRzqP2hrsb4

* docs(appstate): split the dense fatal-outcome bullet into sub-points

The "Any collection fatal" bullet had grown to four distinct ideas
(dispatch behavior, recovery timing, user-facing impact, WA Web
comparison) in one paragraph after the prior review round. Split into
nested sub-bullets, no content change.

Addresses Codex review feedback on PR #473.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fRFZef6tyPkRzqP2hrsb4

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-design breaking-change size-increase-ok Accepted binary-size increase: downgrades the per-PR size gate to a warning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants