Skip to content

fix(dig-node): keep the tier-0 precache loop alive across a round panic (#2044) - #173

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
harden/2044-tier0-round-panic-guard
Aug 3, 2026
Merged

fix(dig-node): keep the tier-0 precache loop alive across a round panic (#2044)#173
MichaelTaylor3d merged 2 commits into
mainfrom
harden/2044-tier0-round-panic-guard

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes dig_ecosystem#2044. Resilience close-out for the tier-0 precache flywheel (the subsystem hardened this session via #2033/#1934/#2041/#2053).

Why

spawn_tier0_precache (crates/dig-node-core/src/tier0_live.rs) runs run_round(...).await each tick inside a bare tokio::spawn loop. The workspace has no panic=abort override (default unwind), so a panic inside a round unwinds and kills the spawned task — the node itself stays up (fail-safe for node availability), but precache then silently stops until the next process restart, degrading the whole tier-0 flywheel. Flagged as a non-blocking residual by the #1934 PR-3 adversarial gate.

What changed

  • New helper run_round_catching<F: Future<Output = RoundOutcome>>(round, tick) -> Option<RoundOutcome> — wraps the round in std::panic::AssertUnwindSafe(round).catch_unwind().await (via futures::FutureExt, already a dependency): OkSome(outcome) (fully transparent on the happy path); Err(_) → a fixed-shape tracing::warn!(tick, …) + None.
  • The loop body becomes let Some(outcome) = run_round_catching(round, tick).await else { continue }; — a panicking round no longer unwinds the task; the next tick runs a fresh round with the carried-over rng/rate.
  • AssertUnwindSafe is sound here: the round's only &mut state (SplitMix64 rng, RoundRateLimiter) are plain value types holding no lock/guard across an await, so a caught mid-round panic leaves them in a valid state and the loop continues safely.
  • A nested-tokio::spawn supervisor was deliberately NOT used — the round borrows non-'static &mut state, so a nested spawn would force restructuring ownership.

run_round's normal (non-panic) outcome/error handling is untouched — the guard only intercepts unwinds, never masks real outcomes. Log message is fixed-shape; the panic payload is never logged (#1603 hygiene).

How verified (TDD)

  • a_panicking_round_is_caught_and_does_not_propagate — builds a REAL run_round(...) with a PanickingProbe (panics in observe_near) and asserts run_round_catching returns None. Non-vacuous: removing the catch_unwind makes the test itself unwind/abort instead of returning — so the guard, not the harness, contains the panic. (A panic backtrace may print to stderr while the test still reports ok.)
  • a_non_panicking_round_returns_its_outcome_unchanged — the catch is transparent on the happy path (Some(outcome)).
  • cargo test -p dig-node-core --lib658 passed, 0 failed; fmt + clippy -D warnings clean; build OK.

Follow-up (filed)

Other tokio::spawn background loops in dig-node-core (chain-watch, subscriptions, gap-fill) may share the same silent-panic-death pattern — a defense-in-depth audit sweep is tracked as dig_ecosystem#2067.

Version

root [workspace.package].version 0.93.4 → 0.93.5 (patch, fix:); Cargo.lock regenerated.


Generated by Claude Code

claude added 2 commits August 3, 2026 16:54
Co-Authored-By: Claude <noreply@anthropic.com>
…ic (#2044)

A panic inside a run_round tick would unwind out of the spawned tokio task
and silently stop tier-0 precache for the rest of the process (the node
itself stays up), until restart. Wrap the round future in catch_unwind via
a small, unit-testable run_round_catching helper: a caught panic becomes a
bounded WARNING + skip-to-next-tick, while a completed round passes through
unchanged. AssertUnwindSafe is sound — the round's only &mut state (rng,
rate limiter) are plain value types with no lock/guard held across an await.

Closes #2044

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d merged commit 1233d5c into main Aug 3, 2026
16 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the harden/2044-tier0-round-panic-guard branch August 3, 2026 17:29
MichaelTaylor3d added a commit that referenced this pull request Aug 3, 2026
…ot Ok(None)

The production `MeltChain` derived a melt from `AnchoredRootResolver::anchored_root()
== Ok(None)`, which was wrong in both directions and made a remote-triggered,
irreversible delete unsafe:

- `Ok(None)` is the node's fail-closed sentinel for "no confirmed generation".
  `CoinsetResolver` produces it for "launcher coin is unspent (store not minted
  yet)" — a store whose lineage has not STARTED. Deleting on it wrongfully erases
  live data and broadcasts a false melt network-wide, correlated across holders.
- A genuine melt never produces it. A melted tip is spent without a datastore
  child, so the lineage walk returns Err("singleton spend did not yield a store"),
  which fail-closed to Unknown — the gate could not fire on a real melt.

Replace it with `confirm_melt_via_chain(&dyn ChainReads, store_id)`, composed from
two POSITIVE chain facts, neither of which re-walks the lineage (#747):

1. The launcher coin whose `coin_id == store_id` exists and is SPENT. `coin_id ==
   store_id` is a 256-bit hash preimage that cannot be ground, so identity is
   pinned to the one unforgeable on-chain anchor, never to a look-alike singleton
   that merely curries `launcher_id == store_id` (#1473). An UNSPENT launcher is
   Live — "not minted yet" is the opposite of melted.
2. The launcher's hint index is NON-EMPTY and every generation under it is SPENT.
   One `coin_records_by_hint(store_id, include_spent = true)` read gives both. The
   non-empty half is load-bearing: an empty index is indistinguishable from an
   un-indexed store, so it resolves to Unknown rather than authorizing a delete.

Every error, absence, or ambiguity resolves away from deletion, and fact 2 is
deliberately conservative — candidates are not launcher-anchored before they count
as live, so an adversary who can plant hints can only suppress a deletion, never
cause one.

Tests: 8 new cases drive the real `ChainReads` trait with crafted coin records —
the link the spy-driven policy tests could not reach. Unused trait methods on the
mock are `unimplemented!()` so the gate cannot silently grow a chain dependency,
and the mock asserts `include_spent = true`. All six inverting mutations of the
gate were confirmed to fail their test.

Also: both new background loops wrap their per-iteration body in
`shared::catch_iteration` (#173/#174/#175) with `recv()`/`tick()` outside the
guard, so a persistently-panicking iteration paces instead of hot-spinning;
`TombstoneSet` recovers from lock poisoning rather than panicking forever after
one contained panic; SPEC.md 14.5 corrected — it documented the `Ok(None)` rule.

root [workspace.package].version -> 0.94.0 (minor, new capability).

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Aug 3, 2026
…ot Ok(None)

The production `MeltChain` derived a melt from `AnchoredRootResolver::anchored_root()
== Ok(None)`, which was wrong in both directions and made a remote-triggered,
irreversible delete unsafe:

- `Ok(None)` is the node's fail-closed sentinel for "no confirmed generation".
  `CoinsetResolver` produces it for "launcher coin is unspent (store not minted
  yet)" — a store whose lineage has not STARTED. Deleting on it wrongfully erases
  live data and broadcasts a false melt network-wide, correlated across holders.
- A genuine melt never produces it. A melted tip is spent without a datastore
  child, so the lineage walk returns Err("singleton spend did not yield a store"),
  which fail-closed to Unknown — the gate could not fire on a real melt.

Replace it with `confirm_melt_via_chain(&dyn ChainReads, store_id)`, composed from
two POSITIVE chain facts, neither of which re-walks the lineage (#747):

1. The launcher coin whose `coin_id == store_id` exists and is SPENT. `coin_id ==
   store_id` is a 256-bit hash preimage that cannot be ground, so identity is
   pinned to the one unforgeable on-chain anchor, never to a look-alike singleton
   that merely curries `launcher_id == store_id` (#1473). An UNSPENT launcher is
   Live — "not minted yet" is the opposite of melted.
2. The launcher's hint index is NON-EMPTY and every generation under it is SPENT.
   One `coin_records_by_hint(store_id, include_spent = true)` read gives both. The
   non-empty half is load-bearing: an empty index is indistinguishable from an
   un-indexed store, so it resolves to Unknown rather than authorizing a delete.

Every error, absence, or ambiguity resolves away from deletion, and fact 2 is
deliberately conservative — candidates are not launcher-anchored before they count
as live, so an adversary who can plant hints can only suppress a deletion, never
cause one.

Tests: 8 new cases drive the real `ChainReads` trait with crafted coin records —
the link the spy-driven policy tests could not reach. Unused trait methods on the
mock are `unimplemented!()` so the gate cannot silently grow a chain dependency,
and the mock asserts `include_spent = true`. All six inverting mutations of the
gate were confirmed to fail their test.

Also: both new background loops wrap their per-iteration body in
`shared::catch_iteration` (#173/#174/#175) with `recv()`/`tick()` outside the
guard, so a persistently-panicking iteration paces instead of hot-spinning;
`TombstoneSet` recovers from lock poisoning rather than panicking forever after
one contained panic; SPEC.md 14.5 corrected — it documented the `Ok(None)` rule.

root [workspace.package].version -> 0.94.0 (minor, new capability).

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants