loopin: cancel probe invoice with a live context

The probe monitor goroutine cancelled the probe invoice with the same
monitoring context used by awaitProbe. The caller cancels that context as soon
as swap initiation returns.

If cancellation races with or precedes the invoice-cancel RPC, the cleanup can
be skipped and the probe invoice may remain open longer than intended.

Fix by calling CancelInvoice with context.WithoutCancel(ctx) so cleanup is not
aborted by caller cancellation.

Add a regression test that cancels the parent context between probe success
and invoice cleanup, and verifies CancelInvoice still receives a live context.
This commit is contained in:
Boris Nagaev 2026-03-01 22:14:07 -05:00 committed by Slyghtning
parent f191775773
commit a5c598dc34
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 85 additions and 1 deletions

View file

@ -362,7 +362,8 @@ func awaitProbe(ctx context.Context, lnd lndclient.LndServices,
// server will know that its probe was
// successful.
err := lnd.Invoices.CancelInvoice(
ctx, probeHash,
context.WithoutCancel(ctx),
probeHash,
)
if err != nil {
log.Errorf("Cancel probe "+

View file

@ -4,14 +4,17 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/chainntnfs"
invpkg "github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
@ -25,6 +28,86 @@ var (
}
)
// probeInvoicesMock is a controllable InvoicesClient mock used by probe tests.
// It allows the test to block and inspect CancelInvoice context behavior.
type probeInvoicesMock struct {
lndclient.InvoicesClient
updateChan chan lndclient.InvoiceUpdate
errChan chan error
cancelCalled chan struct{}
cancelBlock chan struct{}
cancelCtxErr chan error
}
// SubscribeSingleInvoice returns the mock's preconfigured channels.
func (p *probeInvoicesMock) SubscribeSingleInvoice(_ context.Context,
_ lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) {
return p.updateChan, p.errChan, nil
}
// CancelInvoice signals that cancellation was requested, blocks until released
// by the test, then reports ctx.Err() so the test can assert context liveness.
func (p *probeInvoicesMock) CancelInvoice(ctx context.Context,
_ lntypes.Hash) error {
close(p.cancelCalled)
<-p.cancelBlock
p.cancelCtxErr <- ctx.Err()
return nil
}
// TestAwaitProbeCancelInvoiceUsesLiveContext checks that probe-invoice cleanup
// runs with a live context. Specifically, after probe success we cancel the
// parent context before allowing CancelInvoice to proceed, and verify the
// CancelInvoice call still observes ctx.Err() == nil.
func TestAwaitProbeCancelInvoiceUsesLiveContext(t *testing.T) {
t.Parallel()
invoices := &probeInvoicesMock{
updateChan: make(chan lndclient.InvoiceUpdate, 1),
errChan: make(chan error, 1),
cancelCalled: make(chan struct{}),
cancelBlock: make(chan struct{}),
cancelCtxErr: make(chan error, 1),
}
lnd := lndclient.LndServices{
Invoices: invoices,
}
ctx, cancel := context.WithCancel(context.Background())
probeResult, err := awaitProbe(ctx, lnd, lntypes.Hash{1})
require.NoError(t, err)
invoices.updateChan <- lndclient.InvoiceUpdate{
Invoice: lndclient.Invoice{
State: invpkg.ContractAccepted,
},
}
require.NoError(t, <-probeResult)
select {
case <-invoices.cancelCalled:
case <-time.After(time.Second):
t.Fatal("timed out waiting for CancelInvoice call")
}
cancel()
close(invoices.cancelBlock)
select {
case cancelCtxErr := <-invoices.cancelCtxErr:
require.NoError(t, cancelCtxErr)
case <-time.After(time.Second):
t.Fatal("timed out waiting for CancelInvoice to return")
}
}
// TestLoopInSuccess tests the success scenario where the swap completes the
// happy flow.
func TestLoopInSuccess(t *testing.T) {