From a5c598dc347bf6cd812edbed98f207ae51c52b57 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 1 Mar 2026 22:14:07 -0500 Subject: [PATCH] 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. --- loopin.go | 3 +- loopin_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/loopin.go b/loopin.go index 8f3b8286..548aeaa2 100644 --- a/loopin.go +++ b/loopin.go @@ -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 "+ diff --git a/loopin_test.go b/loopin_test.go index b26ff42f..9ff97f73 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -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) {