Skip to content

Watchdog auto-abort is armed at 120s on the mainnet validator; the 07-12 freeze already reached 101.3s (84%) and #3702 makes the abort unrecoverable #3767

Description

@tomerweller

Finding

The mainnet validator's event-loop watchdog has an auto-abort path that calls
std::process::abort(), and on the deployed configuration it is armed at 120 s.
The longest event-loop freeze in this process's 27-day log reached 101.28 s —
84.4% of that threshold
, with a margin of 18.7 s ≈ 1.9 watchdog sample
intervals
.

That freeze (2026-07-12, tracked as #3723) self-recovered. Had it run two
more watchdog samples, the node would have SIGABRT'd itself — and per #3702 the
validator wedges on every restart, so the recovery path from an abort is an
operator-authorized wipe + cold catchup, not a restart.

Neither #3723 nor #3756 (24 comments) mentions watchdog_abort_secs anywhere.
I checked before filing.

The mechanism

crates/app/src/app/mod.rs, deployed 0ac84d42:

pub(crate) fn should_abort(&self) -> bool {
    self.abort_threshold_secs > 0 && self.stale_secs >= self.abort_threshold_secs
}
// Auto-abort: independent of the tier check so that
// any configured threshold (even < 30s) is respected.
if snap.should_abort() {
    if snap.tier() != WatchdogTier::Error { snap.emit_error(); }
    tracing::error!(, "WATCHDOG: Auto-aborting after {}s freeze at phase={}",);
    std::process::abort();
}

The watchdog is a std::thread sampling every 10 s (start_event_loop_watchdog),
so stale_secs advances in 10 s steps: the observed ladder on 07-12 was
23 → 33 → 43 → 53 → 63 → 73 → 83 → 93, and the loop resumed 8.1 s after the
last sample.

It is armed, not disabled

crates/app/src/config.rs:596    #[serde(default = "default_watchdog_abort_secs")]
crates/app/src/config.rs:3806   assert_eq!(AppConfig::mainnet().diagnostics.watchdog_abort_secs, 120);

configs/validator-mainnet-rpc.toml (the deployed config) has no
[diagnostics] section
grep -i 'watchdog\|abort' returns nothing — so the
120 s default applies. watchdog_abort_secs = 0 disables it; the config does not
set it.

The margin, measured

The freeze is measured two ways and they disagree, which matters here. The
watchdog's last sample said stale_secs=93; the loop's own park WARN, emitted on
resume, says 101,280 ms:

2026-07-12T05:04:11.683  WARN  WATCHDOG: Event loop slow (>15s) stale_secs=23 phase=3
2026-07-12T05:04:21.684  ERROR WATCHDOG: Event loop appears frozen! stale_secs=33 phase=3
  … 5 more at 10 s cadence …
2026-07-12T05:05:21.691  ERROR WATCHDOG: Event loop appears frozen! stale_secs=93 phase=3
2026-07-12T05:05:29.113  WARN  Slow event-loop phase … phase_name="broadcast" elapsed_ms=101280
2026-07-12T05:05:29.113  WARN  Consensus stuck - transitioning to out-of-sync
2026-07-12T05:05:29.113  WARN  Lost sync with network - transitioning to syncing state

So the true freeze is 101.28 s, and stale_secs understates it by up to one
sample interval. #3723's title/body say "~90 s"; the park WARN puts it 11 s
higher. Against the abort threshold:

value
observed max freeze 101.28 s
watchdog_abort_secs (default, unoverridden) 120 s
margin 18.72 s
margin in watchdog sample intervals 1.87

Per-day maximum stale_secs as a fraction of the threshold, whole 27-day process
(PID 1512116, never restarted):

day        max stale_secs  % of 120 s   max park ms
2026-07-12       93            78%        101,280   ← #3723, phase=3 broadcast
2026-07-22       54            45%         60,083   ← phase=28 peer_maintenance
2026-07-20       30            25%         31,865   ← phase=3 broadcast
2026-07-07       25            21%         29,282   ← phase=3 broadcast
2026-07-28       24            20%         30,207   ← phase=29 peer_refresh
(21 other days)  ≤24          ≤20%

I am not claiming a trend toward 120 s. The 101 s point is a single outlier;
the phase=3 maxima across the corpus (25.4 / 27.1 / 27.9 / 29.3 / 30.5 / 31.9 /
101.3 s) do not fit a line. The argument is only that one observed sample
already sits at 84% of a threshold whose trip action is unrecoverable on this
deployment.

Why the abort is net-harmful while #3702 is open

std::process::abort() raises SIGABRT immediately. Consequences on this host:

  1. No graceful shutdown. The WatchdogGuard drop, WAL checkpoint, and
    mainnet.lock removal are all skipped. A dirty stop is the documented
    precondition for the VE-12 zero-byte bucket file and the recurring
    knit-to-LCL state-integrity FATAL (FATAL: knit-to-LCL hash mismatch on 6011ec9c — state wipe required #2886/URGENT: knit-to-LCL state-integrity FATAL on b35ea04e during RecoveryEscalation catchup (recurrence of #2886) — validator down ~4.5h, wipe required #2931/URGENT: knit-to-LCL state-integrity FATAL on 384b4f6e during forced near-tip catchup (recurrence of #2931/#2886) — validator down ~5h, wipe required #3282).
  2. Restart does not recover. OFFLINE: restore-from-disk near-tip back-fill stall (NOT binary-specific) — wedges on both f850e6da and 0ac84d42 #3702: the validator wedges on every restart
    (restore-from-disk → Tracking-at-stale-slot → near-tip SCP back-fill drops all
    peers). Recovery is an operator-authorized wipe + cold catchup (~25 min+,
    measured peak 27.1 GB anon RSS per Instrument startup/catchup peak RSS #3228).
  3. Both freezes that reached ERROR tier self-recovered. 07-12 resumed after
    101.3 s and closed L63438849 at 05:05:50; 07-20 and 07-22 likewise. So on the
    evidence available, the abort would have converted three self-healing events
    into three hard outages and prevented none.

The auto-abort exists to break true deadlocks, and that is a good default in
general. It is specifically wrong here because the thing it escalates to is
broken.

Correction I owe on #3756

In #3756 comment
25

I argued that because the deployed binary predates #3690, the next deploy lands
#3690 + #3765 together and therefore "both arms leave the loop in the same step
… that makes the blast-radius cap complete rather than partial."

That is too strong. Park-phase census over the full 27-day log:

phase=28 peer_maintenance  21,649    ← #3690 offloads
phase=29 peer_refresh       5,521    ← #3765 offloads
phase=3  broadcast            263    ← neither
phase=13 maybe_buffered_catchup  2

broadcast is 1.0% of parks but 8 of the 11 ≥30 s ERROR-tier freezes,
including the 101.3 s one. It is a third arm with a much heavier tail, and
neither offload touches it. "Complete" was wrong; the two offloads cap the two
high-frequency arms, not the worst observed freeze.

Suggested action (operator decision — the monitor does not change validator config)

  1. While OFFLINE: restore-from-disk near-tip back-fill stall (NOT binary-specific) — wedges on both f850e6da and 0ac84d42 #3702 is open, set watchdog_abort_secs = 0 (or ≥ 300) in
    configs/validator-mainnet-rpc.toml. Rationale: the abort's recovery path is
    currently a manual wipe, and every observed ERROR-tier freeze self-recovered.
    This is a config change on a running node, so it needs sign-off and — because
    config is read at startup — a restart, which is itself gated on OFFLINE: restore-from-disk near-tip back-fill stall (NOT binary-specific) — wedges on both f850e6da and 0ac84d42 #3702. It may
    therefore have to ride along with the next deploy rather than land alone.
  2. Reconsider the 120 s default. A watchdog whose escalation is abort()
    should be paired with a supervisor that can actually restart the process
    cleanly. Until OFFLINE: restore-from-disk near-tip back-fill stall (NOT binary-specific) — wedges on both f850e6da and 0ac84d42 #3702 closes there is no such supervisor here.
  3. Emit a startup line recording the effective watchdog_abort_secs. There
    is currently no way to read the armed threshold from a running node — I had to
    derive it from config.rs defaults plus the absence of a config section. A
    single INFO at boot would make this auditable.
  4. Consider a warn-only pre-abort tier at, say, 0.75 × threshold, so a
    near-miss is visible as a distinct signal rather than having to be reconstructed
    from a stale_secs ladder after the fact.

Not labelled urgent

No abort has occurred; there is no current validator symptom. Filing per the
label policy as an unlabelled risk/tracking issue.

Related: #3723 (the 101.3 s freeze), #3756 (the park growth that feeds the
stale_secs ladder), #3702 (why the abort is unrecoverable), #3228 (cold-catchup
cost), #3582 / #3598 / #3689 / #3690 (prior arm offloads).

Surfaced by /monitor-tick 2826, 2026-07-28T08:0xZ, deployed 0ac84d42,
PID 1512116, uptime 26.4 d.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions