Skip to content

wtclient: prune closable sessions when the tower is unreachable#10947

Open
jkuchar wants to merge 2 commits into
lightningnetwork:masterfrom
jkuchar:fix-wtclient-dead-tower-cleanup
Open

wtclient: prune closable sessions when the tower is unreachable#10947
jkuchar wants to merge 2 commits into
lightningnetwork:masterfrom
jkuchar:fix-wtclient-dead-tower-cleanup

Conversation

@jkuchar

@jkuchar jkuchar commented Jul 1, 2026

Copy link
Copy Markdown

Fixes #10646

Problem

When every channel backed up by a watchtower session is closed, the session becomes "closable" and handleClosableSessions tries to notify the tower that it may delete the session (via deleteSessionFromTower) before removing the client's own copy.

If the tower is permanently unreachable (offline, or deactivated/removed by the user), that dial fails, the loop continues, and DB.DeleteSession is never reached. As a result:

  • The closable session is never removed from the client DB, so on every restart ListClosableSessions reloads it and the client re-dials the dead tower. With many sessions this keeps lnd busy for a very long time on startup (the reporter saw "hours"), emitting a stream of error deleting session ... from tower: failed to dial tower(...).
  • The tower can never be fully removed, because wtdb.RemoveTower only deletes a tower that has zero sessions — otherwise it just marks it inactive. So the dead tower's sessions, and the tower itself, are stuck forever (also the subject of [feature]: cleanup of wtclient.db / removal of watchtower #7035).

Fix

Notifying the tower is only a courtesy so that it can reclaim disk space: a closable session's channels are all closed, so the local copy no longer protects anything (a breach remedy is impossible once the funding output has been spent by a confirmed close). So:

  • Best-effort local delete: if deleteSessionFromTower fails, we log a warning and still delete the local session copy, so a dead/removed tower can no longer make closable sessions accumulate or block startup.
  • Skip deactivated towers: if the user has deactivated the tower, we skip contacting it entirely and prune the session locally.

Testing

Two new cases in TestClient:

  • a closable session is deleted even when the tower is unreachable;
  • a closable session of a deactivated tower is deleted without contacting the still-reachable tower (asserted by the tower server still holding the session).

The full watchtower/wtclient suite passes.

Not in this PR (possible follow-ups)

  • A --force/purge escape hatch so an operator can immediately remove a dead tower and its remaining sessions (relates to [feature]: cleanup of wtclient.db / removal of watchtower #7035).
  • Bounding the per-dial timeout for the cleanup path (currently up to ConnectionTimeout per address). With the two changes here a dead tower's sessions are pruned after a single failed dial rather than re-dialed forever, so this is an optional optimization.

@github-actions github-actions Bot added the severity-high Requires knowledgeable engineer review label Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

🟠 PR Severity: HIGH

gh pr view | 4 files | 201 lines changed (45 non-test)

🟠 High (3 files)
  • watchtower/wtclient/client.go - watchtower/* (breach remediation client logic)
  • watchtower/wtclient/errors.go - watchtower/* (breach remediation client logic)
  • watchtower/wtclient/manager.go - watchtower/* (breach remediation client logic)
🟢 Low (1 file)
  • watchtower/wtclient/client_test.go - test-only change

Analysis

All substantive changes are confined to watchtower/wtclient, which falls under the watchtower/* category (breach remediation) — classified as HIGH severity, requiring a knowledgeable engineer review.

No bump conditions apply:

  • Only 3 non-test files touched (well under the 20-file threshold).
  • ~45 non-test lines changed (well under the 500-line threshold).
  • No critical packages (lnwallet, htlcswitch, contractcourt, sweep, etc.) are touched, so there's no multi-package critical bump either.

No override labels or prior severity classification were present, so this is a fresh classification.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@ellemouton ellemouton left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

good idea!

one question

Comment thread watchtower/wtclient/client.go Outdated
Comment on lines +578 to +581
if dbTower.Status == wtdb.TowerStatusInactive {
return errTowerInactive
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why put it here? this will only catch things if ErrTowerNotInIterator is returned.. what about the other case?

ie, why not put it on line 589?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The original placement only caught the inactive case because a deactivated (or removed) tower is taken out of the candidate iterator via RemoveCandidate, so GetTower always returns ErrTowerNotInIterator for it. But relying on that inactive ⇔ not-in-iterator invariant is fragile, so this is a fair point.

Putting it literally on line 589 doesn't work either: the in-memory Tower doesn't carry the tower's status, so there's nothing to check there. I've instead moved it to the top of the function — load the DB tower and check Status before the iterator lookup — so both the in-iterator and DB-loaded paths are covered and we no longer depend on the iterator invariant. Updated in the latest push.

Jan Kuchař added 2 commits July 2, 2026 07:44
When all of a watchtower session's channels are closed, the session
becomes "closable" and the client attempts to tell the tower it may
delete the session before removing its own copy. Previously, if the
tower could not be reached, deleteSessionFromTower returned an error and
the session was never deleted from the client's own database. On every
restart the client re-loaded these sessions and re-dialed the dead
tower, which could keep lnd busy for a very long time on startup, and
the tower could never be fully removed because a tower that still has
sessions is only ever marked inactive.

Notifying the tower is only a courtesy so that it can reclaim disk
space: a closable session's channels are all closed, so the local copy
no longer protects anything. We therefore now delete the local session
copy on a best-effort basis even when the tower cannot be reached. In
addition, if the tower has been deactivated by the user, we skip
contacting it entirely and prune the session locally.

Fixes lightningnetwork#10646.
Add a release note for the fix that prunes closable watchtower sessions
locally when the tower is permanently unreachable or has been
deactivated, and add the author to the contributor list (lightningnetwork#10646).
@jkuchar jkuchar force-pushed the fix-wtclient-dead-tower-cleanup branch from c7b6a46 to c3b159a Compare July 2, 2026 07:45
@jkuchar jkuchar marked this pull request as ready for review July 2, 2026 07:50
@jkuchar

jkuchar commented Jul 3, 2026

Copy link
Copy Markdown
Author

@ellemouton is there anything work that needs to be done on this PR? Or should I just wait for the second reviewer?

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

Labels

severity-high Requires knowledgeable engineer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug]: [wtclient] Tower that died make lnd start for hours

2 participants