Skip to content

Commit 6f5ccf7

Browse files
committed
staticaddr/deposit: async finalization cleanup
FinalizeDepositAction only needs to tell the manager to remove the FSM from its active set, but the old synchronous send was still tied to the caller context and could race with request cancellation or a busy manager loop. Send the cleanup notification asynchronously and tie it to the FSM lifetime instead. Withdrawal completion no longer blocks while deposit locks are held just because the original request context was canceled.
1 parent 2884de9 commit 6f5ccf7

2 files changed

Lines changed: 153 additions & 7 deletions

File tree

staticaddr/deposit/actions.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,25 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context,
161161

162162
// FinalizeDepositAction is the final action after a withdrawal. It signals to
163163
// the manager that the deposit has been swept and the FSM can be removed.
164-
func (f *FSM) FinalizeDepositAction(ctx context.Context,
164+
func (f *FSM) FinalizeDepositAction(_ context.Context,
165165
_ fsm.EventContext) fsm.EventType {
166166

167-
select {
168-
case <-ctx.Done():
169-
return fsm.OnError
167+
outpoint := f.deposit.OutPoint
170168

171-
case f.finalizedDepositChan <- f.deposit.OutPoint:
172-
return fsm.NoOp
173-
}
169+
// The finalization notification only tells the manager to remove the
170+
// deposit from its active set. Send it asynchronously so a busy manager
171+
// loop can't stall withdrawal confirmation while deposit locks are held.
172+
go func() {
173+
select {
174+
case <-f.quitChan:
175+
// The deposit is already in a final state. If shutdown wins
176+
// this race, startup recovery will skip it instead of
177+
// re-adding it to the active set.
178+
return
179+
180+
case f.finalizedDepositChan <- outpoint:
181+
}
182+
}()
183+
184+
return fsm.NoOp
174185
}

staticaddr/deposit/actions_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package deposit
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/btcsuite/btcd/chaincfg/chainhash"
9+
"github.com/btcsuite/btcd/wire"
10+
"github.com/lightninglabs/loop/fsm"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestFinalizeDepositActionDoesNotBlock ensures the final cleanup notification
15+
// does not block the withdrawal completion path while the manager loop is busy.
16+
func TestFinalizeDepositActionDoesNotBlock(t *testing.T) {
17+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
18+
defer cancel()
19+
20+
outpoint := wire.OutPoint{
21+
Hash: chainhash.Hash{1},
22+
Index: 1,
23+
}
24+
25+
depositFSM := &FSM{
26+
deposit: &Deposit{
27+
OutPoint: outpoint,
28+
},
29+
quitChan: make(chan struct{}),
30+
finalizedDepositChan: make(chan wire.OutPoint),
31+
}
32+
33+
resultChan := make(chan fsm.EventType, 1)
34+
go func() {
35+
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
36+
}()
37+
38+
select {
39+
case result := <-resultChan:
40+
require.Equal(t, fsm.NoOp, result)
41+
42+
case <-time.After(100 * time.Millisecond):
43+
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
44+
}
45+
46+
select {
47+
case gotOutpoint := <-depositFSM.finalizedDepositChan:
48+
require.Equal(t, outpoint, gotOutpoint)
49+
50+
case <-time.After(time.Second):
51+
t.Fatal("finalization cleanup notification was not delivered")
52+
}
53+
}
54+
55+
// TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup
56+
// notification is tied to the FSM lifetime, not the caller's request context.
57+
func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) {
58+
ctx, cancel := context.WithCancel(context.Background())
59+
defer cancel()
60+
61+
quitChan := make(chan struct{})
62+
defer close(quitChan)
63+
64+
outpoint := wire.OutPoint{
65+
Hash: chainhash.Hash{2},
66+
Index: 2,
67+
}
68+
69+
depositFSM := &FSM{
70+
deposit: &Deposit{
71+
OutPoint: outpoint,
72+
},
73+
quitChan: quitChan,
74+
finalizedDepositChan: make(chan wire.OutPoint),
75+
}
76+
77+
resultChan := make(chan fsm.EventType, 1)
78+
go func() {
79+
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
80+
}()
81+
82+
select {
83+
case result := <-resultChan:
84+
require.Equal(t, fsm.NoOp, result)
85+
86+
case <-time.After(100 * time.Millisecond):
87+
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
88+
}
89+
90+
cancel()
91+
92+
select {
93+
case gotOutpoint := <-depositFSM.finalizedDepositChan:
94+
require.Equal(t, outpoint, gotOutpoint)
95+
96+
case <-time.After(time.Second):
97+
t.Fatal("finalization cleanup notification was dropped after " +
98+
"request cancellation")
99+
}
100+
}
101+
102+
// TestFinalizeDepositActionIgnoresCanceledContext ensures the final cleanup
103+
// notification is still queued even if the caller's context is already done.
104+
func TestFinalizeDepositActionIgnoresCanceledContext(t *testing.T) {
105+
ctx, cancel := context.WithCancel(context.Background())
106+
cancel()
107+
108+
quitChan := make(chan struct{})
109+
defer close(quitChan)
110+
111+
outpoint := wire.OutPoint{
112+
Hash: chainhash.Hash{3},
113+
Index: 3,
114+
}
115+
116+
depositFSM := &FSM{
117+
deposit: &Deposit{
118+
OutPoint: outpoint,
119+
},
120+
quitChan: quitChan,
121+
finalizedDepositChan: make(chan wire.OutPoint),
122+
}
123+
124+
result := depositFSM.FinalizeDepositAction(ctx, nil)
125+
require.Equal(t, fsm.NoOp, result)
126+
127+
select {
128+
case gotOutpoint := <-depositFSM.finalizedDepositChan:
129+
require.Equal(t, outpoint, gotOutpoint)
130+
131+
case <-time.After(time.Second):
132+
t.Fatal("finalization cleanup notification was dropped for " +
133+
"an already-canceled request context")
134+
}
135+
}

0 commit comments

Comments
 (0)