fix(lnd): hold LND's lifecycle lock across a rotation; mock the rotation RPCs
Demo images / Build & push demo images (push) Successful in 3m55s

Reviewing the rotation against what this dev node actually did to LND today —
25 restarts, most of them automatic — surfaced a race the code did not defend
against. Between "stop LND" and "start LND" the rotation owns a stopped
container whose credential material is being deleted, and two background actors
step in there unasked: the health monitor restarts any container it finds
stopped, and the reconciler starts one whose unit is enabled.

Either brings LND back up mid-deletion. LND re-mints macaroons.db on unlock, so
the deletion loop would race a live process writing that file, or "succeed"
against material that had already been regenerated — and the operator would be
told they had rotated while the old root key was still in service. That is the
one outcome this feature exists to make impossible.

It now holds `app_ops::op_lock("lnd")` for the whole rotation. That is the lock
both actors already consult (`lifecycle_op_in_flight`; the health monitor
reaches it through `lifecycle_op_covers_container`), and it additionally
serialises against the package.start/stop/restart workers, so "Restart" on
Lightning mid-rotation queues instead of interleaving. A rotation requested
while one of those is in flight fails fast with a short explanation rather than
waiting silently behind an operation that may itself take minutes.

Deliberately NOT the `user-stopped` marker `recreate_wallet_destructively` uses
for its own window. That marker is a file on disk: a rotation that died between
marking and clearing would leave Lightning suppressed permanently, fixable only
by finding and editing JSON on the node. A lock guard releases when it drops, on
every path including a panic.

Also mocks the three RPCs in mock-backend.js, so the Settings section can be
driven end-to-end without a node — the dev preview otherwise shows only a load
error. The mock advances one step per poll rather than on a timer, which is
deterministic and makes every intermediate state observable.

Verified: cargo check + fmt clean, 6/6 rotation tests, 12/12 component tests,
mock-rpc-parity unchanged (its 2 failures are the in-flight Reticulum panel, not
this), and the three RPCs driven against the live mock through the full arc —
idle → started → 7 steps → ok with the channel count preserved, plus both
password-rejection paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:33:10 -04:00
co-authored by Claude Opus 5
parent 1a98b2d0e7
commit cfa6c6cb0d
3 changed files with 167 additions and 1 deletions
+25
View File
@@ -55,6 +55,31 @@ Two details in that check are deliberate and should not be "tightened":
this node does not hold surfaces as a **failed rotation** with the wallet
intact.
## Nothing else may touch LND mid-rotation
Between "stop LND" and "start LND" the rotation owns a stopped container whose
credential material is being deleted. Two background actors would step in there
unasked: the **health monitor** restarts any container it finds stopped, and the
**reconciler** starts one whose unit is enabled. Either brings LND back up
mid-deletion — and LND re-mints `macaroons.db` on unlock, so the deletion loop
would race a live process writing that file, or "succeed" against material that
had already been regenerated. The operator would be told they had rotated while
the old root key was still in service.
The rotation therefore holds `app_ops::op_lock("lnd")` for its whole duration.
That is the lock both actors already consult (`lifecycle_op_in_flight`, reached
in the health monitor via `lifecycle_op_covers_container`), and it also
serialises against the `package.start`/`stop`/`restart` workers, so an operator
hitting "Restart" on Lightning mid-rotation queues rather than interleaving. A
rotation requested while one of those is running fails fast with a short
explanation instead of waiting silently.
Deliberately **not** the `user-stopped` marker that `recreate_wallet_destructively`
uses for its own window: that marker is a file on disk, so a rotation that died
between marking and clearing would leave Lightning suppressed *permanently*
fixable only by finding and editing JSON on the node. The lock guard releases
when it drops, on every path including a panic.
## The BTCPay coupling — the part that bites
**BTCPay Server keeps its own inline copy of the admin macaroon**, and it cannot