fix(lnd-ui): don't cancel rotation polling before the node reports it running
Demo images / Build & push demo images (push) Successful in 3m24s

Writing the first tests for this section found the bug they were written to look
for. `rotate()` started the poll, then the `load()` immediately behind it took a
status snapshot that did not yet carry `running: true` and cancelled the interval
— so the screen froze on the one action that most needs to show progress. The
operator has just invalidated every credential their wallet holds, the rotation
is genuinely running on the node, and the page tells them nothing is happening
until they reload it by hand.

It survived manual review because the backend flips `running` inside the same
critical section that accepts the request, so the happy path usually wins the
race. "Usually wins a race" is not a property to ship on a credential rotation.

Polling now continues for a bounded window after a request the node accepted,
and stops early as soon as `running` is observed. Bounded, so a request that was
accepted but never acted on stops polling rather than hammering the node.

12 component tests cover the states that carry consequences: the channel census
shown before the button is offered, the stale-BTCPay warning, the difference
between "BTCPay has no internal node" (silence — an absence, not a fault) and
"BTCPay's credential is dead" (a warning), the block on rotating while LND is
unreachable, both poll races above, and that an idle tab does not wake the node.

Verified: 12/12 new, 880/880 frontend tests, vue-tsc clean, and the rebuilt
bundle contains the new strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 08:32:04 -04:00
co-authored by Claude Opus 5
parent a9cefb8326
commit 1a98b2d0e7
2 changed files with 317 additions and 4 deletions
@@ -25,6 +25,20 @@ const confirmError = ref('')
let poll: ReturnType<typeof setInterval> | null = null
/// Epoch ms until which we keep polling even though the node has not reported a
/// running rotation yet.
///
/// Without this the screen can freeze on the one action that most needs to show
/// progress: `rotate()` starts polling, the `load()` right behind it observes a
/// status snapshot that does not yet carry `running: true`, and `syncPolling`
/// cancels the interval. The operator has just invalidated every credential
/// their wallet holds and the page tells them nothing is happening.
///
/// Bounded rather than a plain flag, so a request the node accepted but never
/// acted on stops polling instead of hammering it forever.
let awaitUntil = 0
const AWAIT_START_MS = 120_000
const rotation = computed<LndRotationProgress | null>(() => status.value?.rotation ?? null)
const isRunning = computed(() => rotation.value?.running === true)
@@ -42,10 +56,7 @@ async function load() {
try {
status.value = await rpcClient.lndMacaroonStatus()
loadError.value = ''
// Poll only while there is something to watch, so an idle Settings tab
// isn't waking the node every few seconds.
if (status.value.rotation.running) startPolling()
else stopPolling()
syncPolling()
} catch (e) {
loadError.value = e instanceof Error ? e.message : String(e)
} finally {
@@ -53,6 +64,15 @@ async function load() {
}
}
/// Poll only while there is something to watch, so an idle Settings tab isn't
/// waking the node every few seconds.
function syncPolling() {
const running = status.value?.rotation.running === true
if (running) awaitUntil = 0
if (running || Date.now() < awaitUntil) startPolling()
else stopPolling()
}
function startPolling() {
if (poll) return
poll = setInterval(load, 4000)
@@ -83,6 +103,7 @@ async function rotate() {
try {
await rpcClient.lndRotateMacaroons(password.value)
closeConfirm()
awaitUntil = Date.now() + AWAIT_START_MS
startPolling()
await load()
} catch (e) {
@@ -0,0 +1,292 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LightningCredentialsSection from '../LightningCredentialsSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
lndMacaroonStatus: vi.fn(),
lndRotateMacaroons: vi.fn(),
lndMacaroonRotationProgress: vi.fn(),
},
}))
const STEP_KEYS = ['preflight', 'backup', 'stop', 'remove', 'start', 'verify', 'btcpay'] as const
type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
function steps(overrides: Partial<Record<string, { state: StepState; detail?: string }>> = {}) {
return STEP_KEYS.map((key) => ({
key,
label: `label-${key}`,
state: overrides[key]?.state ?? ('pending' as StepState),
detail: overrides[key]?.detail ?? null,
}))
}
function idleRotation() {
return {
running: false,
ok: null,
started_at: null,
finished_at: null,
error: null,
steps: steps(),
backup_path: null,
identity_pubkey: null,
channels_before: null,
channels_after: null,
new_admin_macaroon_sha256: null,
}
}
function status(overrides: Record<string, unknown> = {}) {
return {
installed: true,
admin_macaroon_sha256: 'a'.repeat(64),
issued_at: '2026-08-08 06:03:11',
identity_pubkey: '024a5fd7de13623aeec81095cf8776fedbc0c4109363022c3ec948196202130b92',
channels_open: 3,
channels_pending: 1,
lnd_error: null,
btcpay_uses_internal_lnd: true,
btcpay_credential_current: true,
rotation: idleRotation(),
...overrides,
}
}
function mountSection() {
return mount(LightningCredentialsSection, {
global: { stubs: { Teleport: true } },
})
}
describe('LightningCredentialsSection', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows what must survive before offering to rotate', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
const wrapper = mountSection()
await flushPromises()
// The channel census is the reassurance an operator needs before clicking a
// button that invalidates every credential their wallet holds.
expect(wrapper.text()).toContain('3 open')
expect(wrapper.text()).toContain('1 pending')
expect(wrapper.text()).toContain('2026-08-08 06:03:11')
// A digest is fine to display; the token itself must never be fetched.
expect(wrapper.text()).toContain('aaaaaaaaaaaaaaaa…')
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
it('warns when BTCPay is stranded on a rotated-out credential', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_credential_current: false }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('BTCPay Server is holding an old Lightning credential')
})
it('stays quiet about BTCPay when there is no internal node to warn about', async () => {
// null means "not configured" — an absence, not a fault. Reporting it as a
// problem would train operators to ignore the warning that matters.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_uses_internal_lnd: false, btcpay_credential_current: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('holding an old Lightning credential')
})
it('blocks rotation while LND is not answering', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
lnd_error: 'LND is not answering on its REST port',
channels_open: null,
channels_pending: null,
identity_pubkey: null,
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not answering right now')
// Without a before-reading there is no way to prove the channels came back,
// so the button must be unavailable rather than merely discouraged.
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('says Lightning is not installed instead of offering a no-op rotation', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, admin_macaroon_sha256: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
expect(wrapper.findAll('button')).toHaveLength(0)
})
async function startRotation(wrapper: ReturnType<typeof mountSection>, pw = 'node-password') {
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue(pw)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
it('sends the password and starts polling for progress', async () => {
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ rotation: { ...idleRotation(), running: true } }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
expect(rpcClient.lndRotateMacaroons).toHaveBeenCalledWith('node-password')
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('keeps polling when the first status after starting has not caught up yet', async () => {
// The node accepts the rotation and then answers a status request that was
// computed a moment earlier, still saying `running: false`. Cancelling the
// poll here would freeze the screen on the one action that most needs to show
// progress — the operator has just invalidated every credential their wallet
// holds and would be told nothing is happening.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('gives up polling if the node never reports the rotation as running', async () => {
// Bounded, so a request that was accepted but never acted on stops polling
// instead of hammering the node forever.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
vi.advanceTimersByTime(180_000)
await flushPromises()
const callsAfterGiveUp = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterGiveUp)
})
it('surfaces a rejected password without starting anything', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockRejectedValue(
new Error('Password verification failed'),
)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Password verification failed')
// The dialog stays open so the operator can correct the password.
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('reports a finished rotation with the re-pair and backup-cleanup steps', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: true,
finished_at: '2026-08-08T12:00:00Z',
steps: steps({
preflight: { state: 'done' },
backup: { state: 'done' },
stop: { state: 'done' },
remove: { state: 'done' },
start: { state: 'done' },
verify: { state: 'done', detail: 'same node, same 3 channel(s)' },
btcpay: { state: 'done' },
}),
backup_path: '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z',
channels_before: 3,
channels_after: 3,
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation complete')
expect(wrapper.text()).toContain('same node, same 3 channel(s)')
expect(wrapper.text()).toContain('Re-pair anything that connects to this node')
// The backup holds the OLD root key, so telling the operator to delete it is
// part of the job, not a nicety.
expect(wrapper.text()).toContain('macaroon-rotation-20260808T120000Z')
})
it('reports a failed rotation as failed rather than silently idle', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: false,
finished_at: '2026-08-08T12:00:00Z',
error: 'backup incomplete — refusing to delete anything',
steps: steps({ preflight: { state: 'done' }, backup: { state: 'failed' } }),
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation failed')
expect(wrapper.text()).toContain('backup incomplete')
})
it('does not poll the node when nothing is running', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
mountSection()
await flushPromises()
const callsAfterLoad = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
})
})