Skip to content

fix(sync)!: stop generating the session percentages that crashed sync_status - #2643

Open
zancas wants to merge 8 commits into
devfrom
sync_status_no_session_percentages
Open

fix(sync)!: stop generating the session percentages that crashed sync_status#2643
zancas wants to merge 8 commits into
devfrom
sync_status_no_session_percentages

Conversation

@zancas

@zancas zancas commented Aug 7, 2026

Copy link
Copy Markdown
Member

An interactive sync died with attempt to subtract with overflow at pepper-sync/src/sync.rs, in the session-percentage divisor of sync_status.

Root cause

The two operands come from different sources. The span is measured once at sync start. The session baseline is a live sum over scanned ranges. SyncStatus::total_outputs_scanned already documents the consequence: the live count "May exceed total_outputs, whose tree bounds are fixed at sync start, when scanning continues past them into chain growth." The subtraction therefore underflows in ordinary operation.

The failure differs by build profile. A debug build panics and takes the calling thread down. A release build wraps the divisor to about 1.8e19 and pins every derived percentage at zero, which reads to a user as a sync that never progresses.

The fix is removal, not a guard

The two session percentages are deleted. They were the only fields whose computation could abort the process, and no consumer read them. The CLI's progress surfaces take total_outputs_scanned, total_outputs, and is_complete(). SyncResult carries the total percentage only. A consumer that wants a session percentage can compute it from the session and total counts the struct still reports.

The surviving session counts now saturate. That covers a different and rarer cause: a re-org truncating the scanned ranges below the baseline.

Blast radius, and the three tests that span it

scan_progress in zingo-cli already guards sync_status with .ok(). That catches a typed error and never an unwind, so the panic escaped into prompt_indicator, reached on every REPL prompt, and into await_sync_narrated. A cosmetic statistic killed the whole session, and the session never read it.

  • The narrowest test pins the class of bug: the unguarded subtraction panics where overflow checks are on and wraps where they are off.
  • The middle test drives sync_status over a wallet in the documented overshoot condition and asserts the exact ratio is reported unreduced.
  • The end-to-end test renders a CLI prompt over such a wallet, which is the surface that actually failed.

Breaking change

SyncStatus::percentage_session_blocks_scanned and SyncStatus::percentage_session_outputs_scanned are removed, along with their entries in the JSON rendering. zingolib::sync re-exports sync_status, so out-of-repo consumers can see these fields; zingo-mobile and zingo-pc should be checked before release. Recorded in the pepper-sync CHANGELOG.

SyncState::set_session_baseline_for_test is added behind test-features so a consumer can regression-test its own progress surface against the overshoot.

A question this raises about where summary statistics belong

Removing these two fields was easy precisely because nothing consumed them, which suggests the sync engine may be the wrong home for summary statistics generally. sync_status currently computes percentages eagerly for every caller, and its three callers want three different things: sync status wants the percentages, SyncResult wants the session counts, and the CLI's progress path wants the raw totals. All three pay for all three on every call.

A percentage is a rendering of a measurement, not a measurement. The engine could report only what it measures, leaving the derived statistics to a domain that owns presentation: a consumer, or the not-yet-landed zingolib::perspective module, which exists for exactly this kind of consumer-facing projection. That would also mean a caller who never asks for a statistic can never be harmed by one, which is the general form of the bug fixed here.

Verified: pepper-sync 104/104, zingo-cli 219/219, libtonode-tests compile, clippy and fmt clean.

🤖 Generated with Claude Code

…_status

An interactive sync died with "attempt to subtract with overflow" at the
session-percentage divisor in sync_status. The two operands come from
different sources: the span is measured once at sync start, while the
session baseline is a live sum over scanned ranges. SyncStatus already
documents that the live count passes the frozen span when scanning
continues into chain growth, so the subtraction underflows in ordinary
operation. A debug build panicked and took the calling thread down; a
release build wrapped the divisor and pinned every derived percentage at
zero.

The two session percentages are removed rather than guarded. They were
the only fields whose computation could abort the process, and nothing
read them: the CLI's progress surfaces take the raw counts and
is_complete, and SyncResult carries the total percentage only. The
session counts that survive now saturate, which covers the rarer case of
a re-org truncating the scanned ranges below the baseline.

Three tests span the blast radius. The narrowest pins the class of bug
itself, asserting that the unguarded subtraction panics where overflow
checks are on and wraps where they are off. The middle one drives
sync_status over a wallet in the documented overshoot condition. The
last renders a CLI prompt over such a wallet, which is the surface that
actually failed: scan_progress guards sync_status with .ok(), and .ok()
catches a typed error but never an unwind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pepper-sync/src/sync.rs
}
// Saturating because the baseline is a stash taken at session start
// while the scanned count is a live sum over scan ranges: a truncation
// on re-org shrinks the live sum below the stash.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is incorrect. the InitialSyncState is always re-calculated after re-org. the chain height is never updated until sync is called again, which triggers a new InitialSyncState to be created after the new chain height is updated. this also covers when scan ranges are truncated in "checked_wallet_height"

Comment thread pepper-sync/src/sync.rs
let session_blocks_scanned =
total_blocks_scanned - sync_state.initial_sync_state.previously_scanned_blocks;
let mut percentage_session_blocks_scanned = ((session_blocks_scanned as f32
/ (total_blocks - sync_state.initial_sync_state.previously_scanned_blocks) as f32)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i dont understand the issue here. total blocks is based on last_known_chain_height (only updated once at sync start) and birthday (immutable). the initial sync state is calculated after "checked_wallet_height" (potentially re-org truncations) and re-calculated in the case of re-org detection during scanning. this means the scan ranges will always align with the last_known_chain_height and no scan ranges will ever span above this height or below the birthday. this means that previously_scanned_blocks must always be lower than total_blocks_scanned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

there is one exception that i am looking into here. as the wallet lock is now given back between checked_wallet_hieght and set_initial_state at sync initialization. if sync_status is called after a re-org truncation has just occured but before it can re-calculate the initial sync state, it may result in previously scanned blocks to become larger than total blocks

Comment thread pepper-sync/src/sync.rs
.previously_scanned_ironwood_outputs,
);
let mut percentage_session_outputs_scanned = ((session_outputs_scanned as f32
/ (total_outputs - previously_scanned_outputs) as f32)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the same logic as above applies to outputs too

Comment thread pepper-sync/src/sync.rs
/// `SyncStatus::total_outputs_scanned` documents that a live scanned
/// count passes the span frozen at sync start "when scanning continues
/// past them into chain growth". Any code that subtracts one from the
/// other therefore meets an unsigned underflow in ordinary operation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is incorrect. the chain does not grow during scanning. the chain height is only updated once in sync initialization and does not scan past the "last_known_chain_height" until sync is called again

Comment thread zingo-cli/src/tests.rs
let mut wallet = client.wallet().write().await;
wallet
.sync_state
.set_session_baseline_for_test(BlockHeight::from_u32(1_000), 70);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this test isnt useful IMO. if we are going to add tests they should be verifying that the is no edge case where the scan range span ever exceeds the initial sync state bounds

@Oscar-Pepper

Oscar-Pepper commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

i don't believe the hypothesis is correct although my investigation has lead me to another potential cause which i described in one of my comments

i think the correct course of action is to still add saturating subs for safety but also hold the wallet lock between checked_wallet_height and set_initial_state. i would rather we dont remove session statistics until we migrate all of this logic into a more suitable place. it is used by zingo-mobile AFAIK

@zancas

zancas commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

As Far As I Know, we're all in agreement with this move:

#2623

So that perspectives are in their own mod.

@zancas

zancas commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

The perspectives mod has now merged.

zancas and others added 3 commits August 8, 2026 12:30
… rig

The nym-feature CI job lints test targets and rejected the three
RT.block_on calls in scan_progress_blast_radius under the ADR 0030
disallowed-methods rule. The rig and its test now carry the same
explicit allowance and crossing justification the file's config-builder
helper established.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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