staticaddr/loopin: keep htlc timeout sweep resumable

A shutdown while publishing or monitoring the HTLC timeout sweep should not
transition the loop-in to Failed.

Return NoOp on context cancellation in those actions so the persisted
state remains a recovery point. Add focused tests for shutdown during
publication retry and confirmation monitoring.
This commit is contained in:
Slyghtning 2026-07-01 15:48:21 +02:00
parent 58fbe2230e
commit 0c0cee377b
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 81 additions and 4 deletions

View file

@ -810,10 +810,10 @@ func (f *FSM) SweepHtlcTimeoutAction(ctx context.Context,
select {
// The context is cancelled when the server is shutting
// down. In that case we give up broadcasting attempts
// and return an error.
// down. Keep the current state so recovery resumes
// broadcasting attempts after restart.
case <-ctx.Done():
return f.HandleError(ctx.Err())
return fsm.NoOp
case <-time.After(htlcTimeoutSweepRetryDelay):
}
@ -847,6 +847,10 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context,
)
if err != nil {
if ctx.Err() != nil {
return fsm.NoOp
}
err = fmt.Errorf("unable to register to the htlc timeout "+
"sweep tx: %w", err)
@ -856,6 +860,10 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context,
for {
select {
case err := <-errChan:
if ctx.Err() != nil {
return fsm.NoOp
}
return f.HandleError(err)
case conf := <-htlcTimeoutTxidChan:
@ -879,7 +887,7 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context,
return OnHtlcTimeoutSwept
case <-ctx.Done():
return f.HandleError(ctx.Err())
return fsm.NoOp
}
}
}

View file

@ -207,6 +207,75 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
}
}
// TestSweepHtlcTimeoutActionNoOpOnShutdown ensures that a shutdown during
// timeout sweep publication keeps the FSM in the same state so it can resume
// after restart.
func TestSweepHtlcTimeoutActionNoOpOnShutdown(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
mockLnd := test.NewMockLnd()
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: mockLnd.Client,
WalletKit: mockLnd.WalletKit,
},
loopIn: &StaticAddressLoopIn{},
}
event := f.SweepHtlcTimeoutAction(ctx, nil)
require.Equal(t, fsm.NoOp, event)
require.Nil(t, f.LastActionError)
}
// TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown ensures that a shutdown
// while waiting for the timeout sweep confirmation keeps the FSM resumable.
func TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
mockLnd := test.NewMockLnd()
sweepAddr, err := mockLnd.WalletKit.NextAddr(ctx, "", 0, false)
require.NoError(t, err)
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
ChainNotifier: mockLnd.ChainNotifier,
},
loopIn: &StaticAddressLoopIn{
HtlcTimeoutSweepAddress: sweepAddr,
InitiationHeight: uint32(mockLnd.Height),
},
}
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorHtlcTimeoutSweepAction(ctx, nil)
}()
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("timeout sweep conf registration not received: %v",
ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
require.Nil(t, f.LastActionError)
case <-time.After(5 * time.Second):
t.Fatal("timeout sweep monitor did not return")
}
}
// TestInitHtlcActionPreservesRouteHints asserts that static-address loop-in
// propagates explicit route hints into the encoded swap invoice sent to the
// server.