Skip to content

fix: force-close channels whose revocation nonce ledger stays unbalanced - #1701

Merged
quake merged 2 commits into
nervosnetwork:developfrom
Alephant6:fix/1561-nonce-stall-watchdog
Oct 26, 2026
Merged

quake merged 2 commits into
nervosnetwork:developfrom
Alephant6:fix/1561-nonce-stall-watchdog

Conversation

@Alephant6

@Alephant6 Alephant6 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Refs #1561.

A stopgap for channels that stay on V1. It bounds the revocation-nonce stall with a force close and doesn't sign anything or change nonce handling, as chainTe suggested in #1561 and #1628.

Problem

When the revocation nonce ledger is left unbalanced at rest (one side missing its send nonce, the other its verify nonce), is_waiting_tlc_ack() blocks AddTlc/RemoveTlc on both sides. waiting_ack is false, so the existing peer-response check never fires, and the channel stays ChannelReady but can never move a TLC again. The ignored #1584 test reproduces it, and in Jepsen it follows ordinary process crashes.

Change

  • After each handled message, if the ledger is unbalanced (ready, not reestablishing, not waiting for an ack), record the time in revocation_nonce_unbalanced_since. A balanced ledger or going offline clears it.
  • On each MaintainChannelTlcs tick, if the ledger has been unbalanced for longer than PEER_CHANNEL_RESPONSE_TIMEOUT, force-close through notify_network_actor_shutdown_me, the path CheckActiveChannel uses. The close comes within one CHECK_CHANNELS_INTERVAL after the timeout (up to about 90 s in release builds).
  • The new field is runtime-only (skip_store). No message, nonce or persisted-state change.

Tests

  • test_revocation_nonce_stall_is_force_closed_after_peer_response_timeout (Payments may remain Inflight after rapid channel reconnects due to a stuck retryable TLC operation queue #1584 schedule): on develop the channel stays ChannelReady (send/verify present: A false/true, B true/false); with this change it is force-closed on the first maintenance tick after the timeout (5/5).
  • test_revocation_nonce_watchdog_keeps_healthy_idle_channel_open: a healthy channel, including one round with a delayed CommitmentSigned, stays open past the timeout and can still pay (5/5).
  • After moving the check to MaintainChannelTlcs: cargo fmt, clippy -D warnings (sqlite) and the fiber::tests::channel module (207 passed) pass. The checks below were run on the first version.
  • cargo fmt, both native clippy runs with -D warnings, typos, make check-migrate, the fnn lib suite (rocksdb and sqlite) and test_node_restart pass. One lib test, mpp::test_mpp_waiting_ack_settlement_hands_off_to_durable_retry_queue, also fails on develop since fix: scope invoice settlement to local payments #1672; it is unrelated.
  • Jepsen with process kills (same code before the rebase): on develop, killing all nodes left stalled channels in 4 of 4 runs. With this change, 13 kill runs ended with no stalled online channel, and the watchdog closed 9.

Questions

  1. Is PEER_CHANNEL_RESPONSE_TIMEOUT (30 s) the right timeout, or should it be longer? The stall is permanent, and in one partition run 30 s closed a channel whose message was only delayed.
  2. Both ends can fire close together (53 ms apart in the test) and both broadcast a commitment. The second is rejected (RBF), and that side stays ShuttingDown until CheckChannelsShutdown sees the peer's commitment on chain. Is that acceptable?

When a channel's revocation nonce ledger is left unbalanced at rest (one side
missing its send nonce, the other its verify nonce), is_waiting_tlc_ack blocks
AddTlc/RemoveTlc on both sides while waiting_ack is false. The existing
peer-response watchdog therefore never fires, and the channel stays
ChannelReady but can never move a TLC again.

After every handled message, track whether the ledger is unbalanced while the
channel is ready, online and not waiting for its own ack. When it becomes
unbalanced, record the time and schedule a CheckRevocationNonceLedger event
that carries it, PEER_CHANNEL_RESPONSE_TIMEOUT later. If the channel still
holds the same timestamp when the event arrives, force-close it through the
existing path. A balanced ledger, or going offline, clears the timestamp. The
new field is runtime-only.

The event carries the timestamp instead of comparing elapsed wall-clock time.
The timer runs on the monotonic clock and can fire a moment before the wall
clock shows the full timeout, so an elapsed-time check can miss, and nothing
would retry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gpBlockchain

Copy link
Copy Markdown
Contributor

Security Review

Result: No reportable findings in the PR #1701 watchdog change and its directly supporting channel, persistence, and force-close paths.
Reviewed range: 2ba4b2559aa885458d9c60469a1e190d71473c15..06e9f3c922e1d0b6f0479bc15c86809c5d46127d
Review model: GPT-6 Codex

Validation:

  • Reviewed the local source and linked deployment records for the existing force-close path.
  • Existing broad tests were not run locally; CI is expected to cover them.

Notes:

  • Active contract binary identity and the published Sphinx crate-to-source mapping remain unverified. Please confirm those deployment identities separately; this review did not establish a PR-specific flaw in either linked component.

@quake
quake requested a review from chenyukang October 21, 2026 01:51
@quake

quake commented Oct 21, 2026

Copy link
Copy Markdown
Member

Could we reuse the existing MaintainChannelTlcs periodic check instead of adding a new event and one shot timer?

We could keep tracking when the nonce ledger becomes unbalanced after each handled message, then check the elapsed time during maintenance and force close if needed. This would avoid the extra event and stale timer handling.

The trade off is timing: maintenance runs every 60 seconds in release builds, so a 30 second timeout could take up to roughly 90 seconds to trigger. If the goal is to prevent an indefinite stall rather than enforce an exact deadline, this seems simpler.

…lcs tick

Drop the CheckRevocationNonceLedger event and its one-shot timer, as
suggested in review. The time the ledger became unbalanced is still
recorded after every handled message, and the periodic MaintainChannelTlcs
tick now force-closes the channel once that is older than
PEER_CHANNEL_RESPONSE_TIMEOUT.

A tick that lands just before the deadline is followed by another one, so
the early-firing timer that made the event carry its own timestamp no
longer matters. The channel is force-closed within one
CHECK_CHANNELS_INTERVAL after the timeout (up to about 90 s in release
builds), which still bounds a stall that would otherwise never clear.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Alephant6

Copy link
Copy Markdown
Contributor Author

@quake Thanks, that's simpler. I've pushed it as a second commit (7021cee). The time the ledger became unbalanced is still recorded after each handled message, and the MaintainChannelTlcs tick now force-closes the channel once that is older than PEER_CHANNEL_RESPONSE_TIMEOUT. The new event and the one-shot timer are gone.

The event only carried a timestamp because a one-shot timer can fire just before the deadline and nothing would retry it. With a periodic check the next tick covers that, so up to about 90 s in release seems fine for a stall that otherwise never clears. Both watchdog tests still pass (5/5), and I've updated the description.

cc @chenyukang, since the change moved after the review request.

@quake
quake merged commit cd97934 into nervosnetwork:develop Oct 26, 2026
45 of 47 checks passed
quake added a commit that referenced this pull request Oct 27, 2026
…nels

`is_revocation_nonce_ledger_unbalanced` guards the V1 per-round ledger
(`remote_revocation_nonce_for_send` / `_verify`). A V2 channel keeps its nonces in
`session_v2` and never advances those fields, so an idle V2 channel looked
permanently unbalanced and #1701's watchdog force-closed it one
`PEER_CHANNEL_RESPONSE_TIMEOUT` after the last round:

    cannot add or remove TLCs: its revocation nonce ledger has been unbalanced for
    over 30000 ms (send_present=false, verify_present=false) ... shutting down it
    forcefully

Channel V2 is enabled by default (`gen_node_features`), so every e2e channel is V2
and `e2e test for router-pay` failed on both CI runs: the channel force-closed
mid-test and the following payment found no route.

Skip the V1 ledger for V2 channels. V2 staleness stays bounded by
`peer_response_wait_v2` / `ack_peer_wait_v2`, which already drive the force close for
a V2 round that is never answered.

Regression test asserts that a V2 channel with both ledger fields cleared neither
reports the stall nor keeps the unbalanced timestamp (it fails without the fix).
quake added a commit that referenced this pull request Oct 27, 2026
…cy channel

`test_revocation_nonce_stall_is_force_closed_after_peer_response_timeout` provokes the
V1 per-round revocation nonce ledger to stay unbalanced and expects the #1701 watchdog
to force close. With channel V2 enabled by default the channel is V2, which does not
use that ledger, so the watchdog no longer fires and the test timed out.

Use `create_legacy_nodes_with_established_channel`, matching the two other ledger tests
and the behaviour this case was written for. The V2 counterpart is covered by
`test_v2_channel_ignores_v1_revocation_nonce_watchdog`.
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.

3 participants