From f191775773884b2557870d53fda1d4c0897499ab Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 1 Mar 2026 22:17:35 -0500 Subject: [PATCH] routing_plugin: release with a live context `ReleaseRoutingPlugin` called `Done` with the caller context. In the loop-out payment flow this cleanup is deferred, so the context may already be canceled by the time teardown runs. That can prevent mission-control restoration in `Done`, leaving plugin-induced routing state behind after a swap exits. Fix this by detaching cancellation in `ReleaseRoutingPlugin` and passing `context.WithoutCancel(ctx)` to `Done`, so teardown still executes during caller cancellation. Also add a regression test that cancels the caller context before release and asserts the plugin `Done` call still receives a live context. --- routing_plugin.go | 15 ++++++++++--- routing_plugin_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/routing_plugin.go b/routing_plugin.go index 8bc0cf9f..9e155305 100644 --- a/routing_plugin.go +++ b/routing_plugin.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "sync" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btclog/v2" @@ -124,9 +125,17 @@ func ReleaseRoutingPlugin(ctx context.Context) { return } - if err := routingPluginInstance.Done(ctx); err != nil { - log.Errorf("Error while releasing routing plugin: %v", - err) + // Use a timeout so a hanging Done call does not block the mutex + // indefinitely, which would prevent other loop-outs from acquiring + // the routing plugin. + releaseCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), 30*time.Second, + ) + defer cancel() + + err := routingPluginInstance.Done(releaseCtx) + if err != nil { + log.Errorf("Error while releasing routing plugin: %v", err) } routingPluginInstance = nil diff --git a/routing_plugin_test.go b/routing_plugin_test.go index b3c2ae79..dd6088f8 100644 --- a/routing_plugin_test.go +++ b/routing_plugin_test.go @@ -692,3 +692,51 @@ func TestRoutingPluginAcquireRelease(t *testing.T) { require.NotNil(t, plugin2) require.NoError(t, err) } + +// mockRoutingPlugin is a minimal RoutingPlugin used to capture the context +// passed to Done. +type mockRoutingPlugin struct { + doneCtxErr error +} + +// Init is a no-op initializer for the mock plugin. +func (m *mockRoutingPlugin) Init(_ context.Context, _ route.Vertex, + _ [][]zpay32.HopHint, _ btcutil.Amount) error { + + return nil +} + +// Done records ctx.Err() so tests can assert whether teardown ran with a live +// context. +func (m *mockRoutingPlugin) Done(ctx context.Context) error { + m.doneCtxErr = ctx.Err() + return nil +} + +// BeforePayment is a no-op hook for the mock plugin. +func (m *mockRoutingPlugin) BeforePayment(_ context.Context, _, _ int) error { + return nil +} + +// TestReleaseRoutingPluginUsesLiveContext checks that ReleaseRoutingPlugin does +// not propagate caller cancellation to plugin teardown. The test cancels the +// caller context before release and verifies mock Done still sees nil ctx.Err. +func TestReleaseRoutingPluginUsesLiveContext(t *testing.T) { + ReleaseRoutingPlugin(context.Background()) + t.Cleanup(func() { + ReleaseRoutingPlugin(context.Background()) + }) + + mockPlugin := &mockRoutingPlugin{} + + routingPluginMx.Lock() + routingPluginInstance = mockPlugin + routingPluginMx.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + ReleaseRoutingPlugin(ctx) + + require.NoError(t, mockPlugin.doneCtxErr) +}