Skip to content

feat: self-healing wedged-behind-network watchdog + snapshot completeness checks#125

Merged
On1x merged 5 commits into
masterfrom
feat/self-heal-wedge-watchdog
Jul 14, 2026
Merged

feat: self-healing wedged-behind-network watchdog + snapshot completeness checks#125
On1x merged 5 commits into
masterfrom
feat/self-heal-wedge-watchdog

Conversation

@chiliec

@chiliec chiliec commented Jul 13, 2026

Copy link
Copy Markdown
Member

Background

A snapshot-synced node whose committed state is missing an object rejects every canonical block that references it (out_of_range at apply_block). This is a rejection livelock, distinct from the #117#120 write-lock deadlock monitors: push_block fails fast, so those monitors never fire. The divergence sits at/below LIB, so fork-switch can't recover — only wipe + snapshot re-import does, and that was gated on empty state, so it never self-triggered. The 2026-07-13 rpc.viz.cx outage sat frozen ~14h and needed a manual docker compose up -d --force-recreate.

Design spec: docs/self-heal-wedge-watchdog-plan.md (included).

What's in this PR (2 commits)

1. feat(snapshot) — post-import completeness/invariant checks (independent, lands first per rollout §5)

  • Per-section object-count reconciliation: header.object_counts vs live index sizes for all multi-instance indices (catches a lossy import).
  • Referential integrity: every account_authority must reference an existing account — the exact invariant the wedge violated.
  • Critical singletons present (dynamic_global_property, validator_schedule, hardfork_property).
  • On failure FC_ASSERT aborts the import — loud + retryable: the P2P snapshot-sync path rejects the incomplete snapshot and tries another trusted peer.

2. feat(p2p) — wedged-behind-network watchdog + recovery

  • Detection (dlt_p2p_node::check_wedge_watchdog, in periodic_task): WEDGED when, sustained WEDGE_CONFIRM_SEC (900s): behind > 200 AND head flat AND gap-fill rejections still climbing. Triple-AND rules out a healthy syncing node (head advancing) and a partition (no rejections). New monotonic _gap_rejected_total drives the "still rejecting" signal (_gap_rejected_count oscillates on blacklist). Pure predicate is_wedged() factored out for table-testing.
  • Action — gated behind auto-resync-on-wedge (default OFF this release; observe elog/marker in the wild first): elog + write force_resync marker next to shared_memory.bin + std::_Exit(2). OFF ⇒ logs only.
  • Recovery (chain plugin_startup): a force_resync marker wipes state so head→0, the existing empty-state gate re-bootstraps from the trusted snapshot peer, then deletes the marker — the exact path a manual --force-recreate triggers. Marker removed only after wipe (crash-safe re-trigger); composes with the resize marker. Lives in the shared-memory dir (via new chain::plugin::get_state_dir) so it persists exactly as long as the state it condemns.

⚠️ Build status — CI build required

The full build could not be run locally (dev machine has Boost 1.90, which removed boost::asio::io_service; project pins Boost ≤1.86 — an environment mismatch unrelated to these changes). Verified locally as far as possible:

  • dlt_p2p_node.cpp, p2p_plugin.cpp: -fsyntax-only clean.
  • chain/plugin.cpp: additions clean (only pre-existing Boost-1.90 API noise on untouched lines).
  • snapshot/plugin.cpp: new block compiles clean in isolation against faithful API mocks (full-file check is parser-desynced by Boost-1.90 fc-reflection errors); all APIs verified against real headers.
  • Wedge predicate truth table: all cases pass (healthy/syncing/partition → not wedged; divergence → wedged only after confirm).

Please confirm the Docker/Linux CI build is green before merge.

Not in scope

  • Root-cause Finding C (undo/fork-switch-after-snapshot seam) — needs testnet reproduction, not a blind fix. The watchdog makes it survivable regardless.
  • §4 integration/negative tests (testnet fault injection, partition-doesn't-wipe) and a unit-test target for is_wedged — need the build environment; the predicate is already factored public/static for that.

🤖 Generated with Claude Code

chiliec added 2 commits July 13, 2026 19:20
The payload checksum only proves the download matches what the serving
node serialized; it cannot detect that the serving node serialized
INCOMPLETE state. A single missing account on the serving side passes
the checksum, imports cleanly, then wedges the node the moment a
canonical block references that account (2026-07-13 rpc.viz.cx incident:
out_of_range "viz-social-bot" at database get_account).

Add three post-import checks inside the import write-lock:
- per-section object-count reconciliation: header.object_counts
  (recorded at export from the serialized section arrays) vs the live
  index sizes for all multi-instance indices. Catches a lossy import.
- referential integrity: every account_authority must reference an
  existing account (the exact invariant the wedge violated).
- critical singletons present: dynamic_global_property,
  validator_schedule, hardfork_property.

On failure FC_ASSERT aborts the import — loud and retryable: the P2P
snapshot-sync path rejects the incomplete snapshot and tries another
trusted peer instead of silently starting on corrupt state.
A snapshot-synced node whose state is missing an object rejects every
canonical block that references it (out_of_range at apply_block). This
is a rejection livelock, distinct from the #117-#120 write-lock deadlock
monitors: push_block fails fast, so those monitors never fire. The
divergence sits at/below LIB, so fork-switch cannot recover — only
wipe + snapshot re-import does, and that was gated on empty state, so it
never self-triggered. The 2026-07-13 rpc.viz.cx outage needed a manual
docker compose --force-recreate after ~14h frozen.

Detection (dlt_p2p_node::check_wedge_watchdog, in periodic_task):
declare WEDGED when, sustained for WEDGE_CONFIRM_SEC (900s):
  behind > WEDGE_BEHIND_THRESHOLD (200) AND head flat AND gap-fill
  rejections still climbing. The triple-AND rules out a healthy syncing
node (head advancing) and a partition (behind+flat but no rejections).
A new monotonic _gap_rejected_total drives the "still rejecting" signal
because _gap_rejected_count oscillates (resets on blacklist). The pure
predicate is_wedged() is factored out for table-testing.

Action (gated behind auto-resync-on-wedge, default OFF for this release
so we observe the elog/marker in the wild first): elog, write a
force_resync marker next to shared_memory.bin, then std::_Exit(2)
(precedent: undo_all watchdog). With OFF, the watchdog only logs.

Recovery (chain plugin_startup): a force_resync marker wipes state so
head becomes 0, and the existing empty-state gate re-bootstraps from the
trusted snapshot peer, then deletes the marker — the exact path a manual
--force-recreate triggers today. The marker is removed only after the
wipe, so a crash mid-wipe re-triggers recovery; it composes with the
resize marker (wipe clears it too). The marker lives in the shared-memory
dir (exposed via chain::plugin::get_state_dir) so it persists exactly as
long as the state it condemns.
@chiliec chiliec force-pushed the feat/self-heal-wedge-watchdog branch from f3ffe3f to 455a767 Compare July 13, 2026 11:23
…cro-arg UB

The completeness-check block lives inside the db.with_strong_write_lock([&]{...})
lambda, and with_strong_write_lock is itself a function-like macro. The local
#define/#undef CHECK_SECTION therefore sat inside that macro's argument list,
which is undefined behavior — GCC silently dropped the #define, leaving
CHECK_SECTION undeclared and breaking the build.

Replace the macro with 28 direct check_section(...) calls via a small local
lambda; identical behavior, no preprocessor directives inside the macro arg.
Comment thread plugins/chain/plugin.cpp Outdated
// 2026-07-13 rpc.viz.cx incident). Fork-switch can't recover state below
// LIB — only wipe + snapshot re-import can. Honor the marker by wiping
// state so head becomes 0; the empty-state gate later in this function then
// re-bootstraps from a trusted snapshot peer. This is the exact recovery a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[major] Recovery can re-import a self-created snapshot and re-wedge (crash loop). This comment says the empty-state gate "re-bootstraps from a trusted snapshot peer", but the gate further down first honors my->snapshot_path (set from --snapshot / --snapshot-auto-latest). db.wipe(..., true) deletes shared_memory.bin + block_log but not the snapshots/ dir. On a node that serves/creates periodic snapshots (rpc.viz.cx runs allow-snapshot-serving), auto-latest can rediscover a snapshot generated from the already-corrupt state, re-import it, and wedge again. The new completeness checks (commit 1) may not break the loop — see the count-reconciliation note. Suggest forcing trusted-peer bootstrap on force_resync: skip the local snapshot_path (or ignore snapshots/) for this boot so recovery pulls a fresh snapshot from a trusted peer, matching the comment's stated intent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5b1d4cd. On a force_resync boot the recovery block now clears my->snapshot_path and sets my->auto_recover_from_snapshot = false (plugins/chain/plugin.cpp:559-560) before the snapshot-load gate. So neither --snapshot / --snapshot-auto-latest nor a node-local snapshots/ file (which db.wipe(..., true) does not remove) can re-import state serialized from the corrupt db. The node comes up empty and re-bootstraps from a trusted snapshot peer (or full P2P resync), matching the comment's stated intent. Confirmed the production config.ini relies on sync-snapshot-from-trusted-peer + trusted-snapshot-peer for exactly this path (no in-wire snapshot fetch otherwise).

Comment thread libraries/network/dlt_p2p_node.cpp Outdated
return;
}

bool rejects_climbed = (_gap_rejected_total > _wedge_reject_baseline);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[major] rejects_climbed is sticky for the whole window, weakening the partition safeguard. _wedge_reject_baseline is captured once at window open (L3793), so a single rejection anywhere in the 900s window keeps rejects_climbed == true until WEDGE_CONFIRM_SEC. The design intent is "rejections still climbing" — to tell genuine divergence apart from a partition. As written, a node that took one early rejection and then went silent (partial isolation) still satisfies the predicate; with the flag ON it would wipe good state. Suggest re-baselining per relog interval (or requiring a rejection within the last WEDGE_RELOG_SEC) so "climbing" means sustained, not "happened at least once".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5b1d4cd. Replaced the once-per-window baseline with recency: _wedge_last_reject is stamped on each observed rejection, and rejects_climbed is true only when a rejection occurred within the last WEDGE_RELOG_SEC (dlt_p2p_node.cpp:3844-3845). A node that took one early rejection and then went silent no longer satisfies the predicate — "climbing" now means sustained, which is what distinguishes genuine divergence from a partial isolation.

Comment thread libraries/network/dlt_p2p_node.cpp Outdated
uint32_t our_head = _delegate->get_head_block_num();
if (our_head == 0) return; // not bootstrapped yet (or mid snapshot import)

uint32_t behind = (_highest_seen_block_num > our_head)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[major] _highest_seen_block_num is monotonic and peer-influenced — one bogus advertised head permanently arms the watchdog. It only ever increases (set from peer messages at ~L1506/1691/1920) and is never validated or clamped. A single malicious/buggy peer advertising a head far above the real tip makes behind huge forever, so the "behind" gate stays permanently open. Combined with head-flat + any rejection (flag ON), this is an operator-invisible route to an auto-wipe under an eclipse-style scenario. Suggest clamping an implausibly-far tip (e.g. vs local wall-clock/expected rate) or requiring corroboration from >=2 peers before feeding it into the wedge decision.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5b1d4cd. The wedge decision no longer feeds on the monotonic _highest_seen_block_num. network_tip is now the second-highest head across established (SYNCING/ACTIVE) peers, and requires at least WEDGE_MIN_CORROBORATING_PEERS of them (dlt_p2p_node.cpp:3782-3794); when under-corroborated the tip collapses to 0, behind collapses to 0, and the watchdog disarms. A single outlier/malicious advertised head can no longer move the tip or arm an eclipse-style auto-wipe.

// multi-instance index is cleared before import (see clear block above),
// so the live index size must equal the recorded count. A mismatch
// means the import silently dropped objects.
auto expect_count = [&](const std::string& section, size_t actual) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[medium] Count-reconciliation cannot detect export-side incompleteness — which is the actual incident class. header.object_counts is computed at export from the same serialized arrays (plugin.cpp:1177). If the serving node serialized incomplete state (the viz-social-bot incident), the recorded count already equals the short array, so on import live == recorded and this passes. A whole section emptied by a chainbase type-enum shift reads 0 == 0 too. The only real net for the incident is the account_authority -> account referential check below, and only if the dangling authority survived. The stronger Finding-A check (dgp.current_supply vs summed balances) is not implemented — consider adding it. Also worth softening the commit message, which presents count-reconciliation as catching the incident.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged — this is the real incident class and count-reconciliation cannot catch it. The in-code comment now documents that the section counts only catch lossy import, and that the account_authority -> account referential check is the only existing net for export-side gaps (and only if a dangling ref survives).

The proper detector is a value invariant (current_supply vs summed balances; total_vesting_shares vs Σ account.vesting_shares). database::validate_invariants() is declared-only — no definition anywhere in git history — so this needs the accounting written from scratch and, critically, validated against a real mainnet snapshot before arming; a wrong invariant would reject valid snapshots, which is worse than the status quo. Deferred to a follow-up on a build machine (no build env / real snapshot in this round). The full accounting model is designed: the TOKEN identity (current_supply == Σ balances + reserved + escrow + invite + vesting_fund + reward_fund + committee_fund + Σ validator.pending_stakeholder_reward) plus a safe SHARES-only fallback (total_vesting_shares == Σ account.vesting_shares, delegations net to zero). It'll be implemented as a soft ++invariant_failures in the same post-import block so a mismatch triggers the retry-another-peer path.

Comment thread plugins/chain/plugin.cpp Outdated
if (boost::filesystem::exists(shm)) boost::filesystem::remove(shm);
}
// Remove the marker only after the wipe so a crash mid-wipe re-triggers recovery.
boost::filesystem::remove(force_resync_marker);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[minor] This remove() can throw and abort plugin_startup. It sits outside the surrounding try/catch. If it throws (file locked, permission), recovery aborts here; with the crash-safe re-trigger design that can turn into a crash loop. Use the non-throwing error_code overload.

Suggested change
boost::filesystem::remove(force_resync_marker);
boost::system::error_code _rm_ec;
boost::filesystem::remove(force_resync_marker, _rm_ec);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5b1d4cd — the marker removal now uses the non-throwing error_code overload and logs on failure (plugins/chain/plugin.cpp:566-570), so a locked/permission-denied marker can no longer throw out of plugin_startup.

Comment thread plugins/snapshot/plugin.cpp Outdated
// preprocessor directive inside a macro argument list is undefined
// behavior (GCC silently drops the #define, leaving CHECK_SECTION
// undeclared and the build broken).
auto check_section = [&](const std::string& section, size_t actual) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[minor] check_section is a trivial pass-through to expect_count. It duplicates behavior (future changes must touch two lambdas). Now that the #define/#undef is gone, the 28 call sites can call expect_count(...) directly and this wrapper can be dropped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5b1d4cd — dropped the check_section pass-through; the call sites now invoke expect_count(...) directly (plugins/snapshot/plugin.cpp:1832+).

chiliec added 2 commits July 14, 2026 11:28
…t, no re-wedge loop

Addresses On1x review on PR #125:

[major] Recovery re-import loop (plugins/chain/plugin.cpp): db.wipe() does not
clear the snapshots/ dir, so on a snapshot-serving node --snapshot-auto-latest
could rediscover a snapshot serialized FROM the corrupt state and re-wedge. On
force_resync, drop the local snapshot_path and disable auto_recover_from_snapshot
so no node-local snapshot is silently re-imported; the node comes up empty and
resyncs from the network. Corrected the comments: there is no in-wire
snapshot-fetch-from-peer, recovery is network resync or an operator-supplied
snapshot.

[major] Sticky rejects (dlt_p2p_node): _wedge_reject_baseline was captured once
at window open, so a single early rejection kept rejects_climbed true for the
whole 900s window (a node that rejected once then went silent would falsely
confirm). Replace with recency tracking: "climbing" now means a rejection was
observed within the last WEDGE_RELOG_SEC — sustained, not "happened once".

[major] Unvalidated network tip (dlt_p2p_node): _highest_seen_block_num is
monotonic and peer-influenced, so one bogus advertised head armed the watchdog
forever (eclipse-style auto-wipe). Derive a corroborated tip = 2nd-highest head
across established (SYNCING/ACTIVE) peers, requiring >= WEDGE_MIN_CORROBORATING_PEERS;
a single outlier can no longer move it, and an under-corroborated node never arms.

[medium] Count-reconciliation scope (plugins/snapshot/plugin.cpp): documented
that header.object_counts is derived at export from the same arrays, so check (1)
catches lossy IMPORT only, not export-side incompleteness; the referential check
is the real net for the incident. The stronger supply-vs-balances invariant is
deferred (database::validate_invariants() is declared but not defined; a
hand-rolled partial accounting would risk rejecting valid snapshots).

[minor] Marker remove() could throw outside the try/catch and abort
plugin_startup (crash loop) — use the non-throwing error_code overload (also for
the fallback shared_memory.bin remove).

[minor] Dropped the trivial check_section pass-through; the 28 sites now call
expect_count directly.
The wedge watchdog only takes its destructive wipe+resync action when the
pure is_wedged() predicate returns true, so pin its boolean contract with a
regression guard. The predicate's semantics changed in the review-fix round
(rejects_climbed is now recency-based, behind is corroborated-tip-based)
though its signature did not.

- Promote is_wedged() and its WEDGE_BEHIND_THRESHOLD / WEDGE_CONFIRM_SEC
  thresholds into a public: block so the test can assert boundaries
  symbolically. The predicate was documented as table-testable but was
  actually private; out-of-line constexpr defs and other .cpp references
  are unaffected by the access change.
- Add tests/consensus_sim/scenarios/test_wedge_predicate.cpp: 8 cases
  covering the sole confirming combination, each dimension vetoed
  individually, and both boundaries (behind strictly >, elapsed inclusive >=).
  Wired into the existing Boost.Test runner and linked graphene::network.

Runs only under -DBUILD_CONSENSUS_TESTS=ON + ctest (opt-in, as with the
other consensus_sim scenarios); the current Docker CI builds only
vizd/cli_wallet so it compiles the header change but not this test.
@chiliec

chiliec commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

@On1x thanks for the review — all 6 threads addressed and replied inline. Summary:

Fixed in 5b1d4cd (verified in current code):

  • [major] re-wedge crash loopforce_resync now clears snapshot_path + disables auto_recover_from_snapshot before the load gate, so no node-local snapshot (incl. surviving snapshots/) can re-import corrupt state; recovery pulls a fresh trusted-peer snapshot.
  • [major] sticky rejects_climbed — now recency-based (rejection within the last WEDGE_RELOG_SEC), so a single early rejection then silence no longer confirms.
  • [major] monotonic _highest_seen_block_num — wedge tip now = 2nd-highest head across ≥2 established peers (anti-eclipse); under-corroborated ⇒ disarms.
  • [minor] remove() can throw — switched to the non-throwing error_code overload.
  • [minor] check_section wrapper — dropped; call sites use expect_count(...) directly.

Deferred (with sign-off) to a build-machine follow-up:

  • [medium] export-side value invariantvalidate_invariants() is declared-only; the proper supply-vs-balances / shares invariant must be validated against a real mainnet snapshot before arming (a wrong invariant rejects valid snapshots). Full accounting model is designed; tracked as a follow-up.

Also added this round: a truth-table unit test for the is_wedged predicate (tests/consensus_sim/scenarios/test_wedge_predicate.cpp, dded0d5) and made the predicate public. CI is green. Ready for another look when you have a moment.

@chiliec chiliec requested a review from On1x July 14, 2026 04:27

@On1x On1x left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

все ок, мерджим

@On1x On1x merged commit 11f068b into master Jul 14, 2026
1 check passed
@chiliec chiliec deleted the feat/self-heal-wedge-watchdog branch July 14, 2026 06:21
On1x added a commit that referenced this pull request Jul 14, 2026
…riant

feat(snapshot): supply/value conservation invariants on import (#125 follow-up)
On1x added a commit that referenced this pull request Jul 14, 2026
…126)

Brings the wedged-behind-network watchdog (#125) and the snapshot
post-import completeness/supply invariants (#126) onto the PM branch.

The #125 checks (count reconciliation, referential integrity, singletons)
and the #126 SHARES conservation check are PM-agnostic and stay fatal.

The #126 TOKEN supply invariant is extended for PM: PM is zero-sum, so a
bet / liquidity / lazy-deposit / commit-escrow / dispute-fee / oracle-
insurance / leverage-collateral moves TOKEN out of account.balance into a
PM object field. Those pools are now summed (minimal non-overlapping set,
duplicates such as bets_sum/reserves, empty-provider lazy LP, and
deposit.principal excluded).

Because this PM accounting is not yet satoshi-validated against real PM
snapshots, the TOKEN check is LOG-ONLY on this branch (per-component delta
logged, import NOT failed) until reconciled to delta==0 and re-armed as a
fatal FC_ASSERT. Tracked in #127 with a TODO(pm) at the check.

Conflicts: tests/consensus_sim/CMakeLists.txt (kept both test_pm_lifecycle
and test_wedge_predicate scenarios).
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