fix: large-wallet sync starvation + shutdown/console-flash UX (Windows full node) #2

Open
DanS wants to merge 12 commits from fix/balance-poll-sync-contention into dev
Owner

Summary

Fixes found while diagnosing a Windows full-node wallet that appeared stuck "downloading blocks" on a large (~5.3k-tx) fully-shielded wallet. Root cause was not the network — the wallet was starving the daemon's single block-connect thread. Five commits:

  1. 8707934 sync starvation — the Overview balance poll (z_gettotalbalance, O(mapWallet) under cs_main, ~20s on this wallet) fired every ~2s and held cs_main, so ActivateBestChain/ConnectBlock only ran in the gaps and the node fell further behind than it caught up. Fix: adaptive balance cadence (next poll waits >= scan-cost / 10%) + sync-settle hysteresis so it can't re-starve near tip; wallet mutations still force an immediate balance refresh.
  2. 29274c2 startup/shutdown UX — trim the verbose "taking longer than expected" notice; the shutdown screen now tails debug.log (windowless) for external daemons so the node flush/exit is visible.
  3. 7e8b99a shutdown safety — confirm before stopping the daemon mid Sapling-witness-rebuild (stopping discards it -> multi-minute redo next launch): Keep-running / Stop-anyway / Cancel.
  4. a2f84be console-flash on launch — spawn dragonxd with CREATE_NO_WINDOW instead of CREATE_NEW_CONSOLE.
  5. 0942691 windowless shell-outs — route the remaining GPU/xmrig/wallet-rebuild _popen calls through a CreateProcess+CREATE_NO_WINDOW helper (Platform::runHiddenCapture) so they never flash cmd.exe.

Testing

  • Linux build + test_phase4 green on every commit; Windows cross-compile (--win-release) verified per commit.
  • Root cause live-verified on a running mainnet node: z_gettotalbalance/z_listunspent ~20s each holding cs_main; one core pegged on GetFilteredNotes; block-connect thread in Wait; download idle (~17 B/s) with all peers at max in-flight.

Out of scope (separate, unvalidated)

A daemon-side fix to persist witness-rebuild progress across an interrupted rescan lives in the external/dragonx repo on branch fix/witness-rebuild-checkpoint. It is not compiled or tested (fund-adjacent) and must pass a node build + regtest/testnet/mainnet validation + adversarial review before shipping — intentionally excluded from this PR.

🤖 Generated with Claude Code

## Summary Fixes found while diagnosing a Windows full-node wallet that appeared stuck "downloading blocks" on a large (~5.3k-tx) fully-shielded wallet. Root cause was **not** the network — the wallet was starving the daemon's single block-connect thread. Five commits: 1. **`8707934` sync starvation** — the Overview balance poll (`z_gettotalbalance`, O(mapWallet) under `cs_main`, ~20s on this wallet) fired every ~2s and held `cs_main`, so `ActivateBestChain`/`ConnectBlock` only ran in the gaps and the node fell further behind than it caught up. Fix: adaptive balance cadence (next poll waits >= scan-cost / 10%) + sync-settle hysteresis so it can't re-starve near tip; wallet mutations still force an immediate balance refresh. 2. **`29274c2` startup/shutdown UX** — trim the verbose "taking longer than expected" notice; the shutdown screen now tails `debug.log` (windowless) for external daemons so the node flush/exit is visible. 3. **`7e8b99a` shutdown safety** — confirm before stopping the daemon mid Sapling-witness-rebuild (stopping discards it -> multi-minute redo next launch): Keep-running / Stop-anyway / Cancel. 4. **`a2f84be` console-flash on launch** — spawn `dragonxd` with `CREATE_NO_WINDOW` instead of `CREATE_NEW_CONSOLE`. 5. **`0942691` windowless shell-outs** — route the remaining GPU/xmrig/wallet-rebuild `_popen` calls through a `CreateProcess`+`CREATE_NO_WINDOW` helper (`Platform::runHiddenCapture`) so they never flash `cmd.exe`. ## Testing - Linux build + `test_phase4` green on every commit; Windows cross-compile (`--win-release`) verified per commit. - Root cause live-verified on a running mainnet node: `z_gettotalbalance`/`z_listunspent` ~20s each holding `cs_main`; one core pegged on `GetFilteredNotes`; block-connect thread in Wait; download idle (~17 B/s) with all peers at max in-flight. ## Out of scope (separate, unvalidated) A daemon-side fix to persist witness-rebuild progress across an interrupted rescan lives in the `external/dragonx` repo on branch `fix/witness-rebuild-checkpoint`. It is **not compiled or tested** (fund-adjacent) and must pass a node build + regtest/testnet/mainnet validation + adversarial review before shipping — intentionally excluded from this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
DanS added 12 commits 2026-08-31 00:02:42 -05:00
Send and Receive are now consistent in layout, and the Send recipient row's buttons
render correctly.

Card envelope (Send ⇄ Receive consistency):
- Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's
  width + centering (fill the available column up to content-max-width, then center). Both
  tabs derive their card from it, so they can't drift again. Previously Send capped at
  760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider
  than ~860dp; now they fill available width identically.

Receive:
- Justify the footer buttons edge-to-edge (equal shares over the live count) instead of
  left-clustering with dead space, matching Send's full-width footer rhythm.
- Build the address-dropdown preview to the combo's real pixel width so the trailing
  balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation).

Send recipient row (input | Paste | contacts-icon):
- Pin the contacts icon button to the frame height so the larger iconMed font doesn't
  auto-size it taller than Paste/the input.
- Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no
  longer overshoots the card and clips the icon's right border.

draw_helpers (root cause, app-wide):
- TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which
  CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph
  off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that
  passes an explicit size and a "##id" label; no-op for labels without "##".

Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards
byte-identical at L=1174/R=2773). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings tabs brought closer to the approved mockup:
- ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px
  padX, Primary → accent-outline chip, Secondary/card buttons more defined.
- Daemon-binary card: compact status right-aligned on the DAEMON BINARY
  heading (Up to date / Version differs / Not installed), filled/rounded
  status box, neutral danger divider (was alarming red), roomier spacing.
- RPC Connection: two-row column-aligned layout (Host | Port, then
  Username | Password) so the password no longer clips off the card edge.
- Chat settings tab: live conversation preview below the Appearance /
  Messaging cards; "Focus input on open" checkbox reflowed onto the console
  color-toggle row.
- Debug Options: "Current theme only" toggle restricts either screenshot
  sweep to the active theme instead of cycling every skin.
- Tabs fill the full content width (content-max-width cap disabled) and the
  sidebar nav panel centers within the true visible area.
- i18n: new keys for the above (untranslated keys fall back to English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and
returned early (added in 1752500 "themed DragonX logo"), so the app/product
branding — the top-left header (app.cpp AddImage) and the About tab
(getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon
logo. Drop that step so logo_tex_ resolves via the intended path: active-skin
override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk,
then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance
card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "The DragonX Developers" to the About-tab credits (after The Hush
Developers), acknowledging the DragonX chain/daemon this wallet drives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BDB wallet.dat bloats with shielded-note witness data and never shrinks
in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet
Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a
"Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress)
dialog. Full-node only (lite has no wallet.dat here); threshold is a single
named constant. i18n keys fall back to English for non-English locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat
first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a
clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge
to Address. The persisted large_wallet_warned flag keeps it once-only and
re-arms if the file later shrinks back under the threshold.

- AlertRecord gains an optional onClick + actionHint; Notifications::action()
  pushes a toast and a clickable history entry. renderAlertHistoryPanel() now
  renders the accent action link (under the message) and measures true content
  height so wrapped messages + the link aren't clipped.
- App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per
  launch from update(); reuses the existing wallet_size_warn/consolidate strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Merge to Address actually serve wallet-bloat consolidation and be far less
opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet
Settings banner + alert action) frames it as "Consolidate funds" and targets
shielded notes — the bloat the nudge warns about.

- Source selector: consolidate shielded notes (ANY_SAPLING), transparent
  (ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never
  reduced the shielded-witness bloat. Batch limit now applies to the right side.
- Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent)
  and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when
  the set exceeds one batch.
- Destination auto-selects the best spendable z-address (button enabled by
  default); empty wallets get an inline "Create shielded address" (z_getnewaddress).
- Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO
  Limit" jargon) with sane defaults.
- Inline confirm step before the fund-moving call (amount + input count + dest).
- Live progress: self-polls z_getoperationstatus to show Consolidating… →
  Done/Failed, replacing the raw opid + manual "Check status" button.

All three merge entry points now use the typed showMerge()/showConsolidate()
(no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode
keeps working. New i18n keys fall back to English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and
holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The
Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was
held almost continuously, starving the single block-connection thread: the node
connected blocks only in the gaps between polls and could fall further behind
the tip than it caught up (observed live: gap growing 58→100 blocks while the
GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download).

Two hardening changes on top of the existing "skip balance while syncing" guard:

- Hysteresis: keep the low-impact sync profile (and balance suppression) for a
  short settle window after catching up, so a large-wallet scan can't
  immediately re-starve connection and bounce the node back into syncing. Armed
  only on the syncing→caught-up edge, so a wallet synced from the start is never
  throttled at connect (effectivelySyncing()).

- Adaptive balance cadence: time each z_gettotalbalance scan and require the
  next poll to wait at least (cost / 10%), so balance scanning never occupies
  more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core
  timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations
  (send/shield) force the next poll through so the user's own action updates the
  balance immediately (balanceRefreshDue()).

getblockchaininfo keeps its normal cadence throughout, so sync progress stays
live. Build + test_phase4 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Loading "taking longer than expected" notice: shorten the body + hint so the
  startup screen reads less wordy (same info, ~half the text).
- Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured
  stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output"
  panel was always empty, leaving just a spinner. Fall back to tailing the
  daemon's debug.log so the user can watch the node flush the block index and
  exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stopping dragonxd while it's rebuilding the Sapling witness cache discards the
in-progress work — BuildWitnessCache aborts on shutdown without persisting — so
the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang).
This bites especially with stop_external_daemon enabled, where wallet exit sends
the node a stop.

beginShutdown() now defers when it would StopDaemon while a rebuild is active and
shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up
to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the
debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness /
Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines).
The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE
allocates a console window that flashes on screen before SW_HIDE hides it — visible as a
console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to
CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher);
dragonxd logs to debug.log, not a console, so nothing is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add
Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on
Windows, popen on POSIX — and route the remaining shell-outs through it:
- GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu")
- xmrig discovery + version (findXmrigBinary "where xmrig.exe"; "<bin> --version", stderr merged)
- wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param

None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each
would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/balance-poll-sync-contention:fix/balance-poll-sync-contention
git checkout fix/balance-poll-sync-contention
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DragonX/ObsidianDragon#2