multi/test: add unit tests for gossip result helpers and context bridge
What changed, and why it matters
This commit only adds and updates unit tests. It does not change any production code, so it cannot introduce a security vulnerability or fix one directly. The tests verify that helper functions behave correctly when futures complete, contexts cancel, or goroutines shut down. A test comment that previously described a 'Network Isolation Attack' regression was removed, but the actual regression test remains and the production behavior it exercises was already in place before this commit.
No security action required. Review the tests as normal quality-assurance code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff is entirely test code: new tests in actor/future_test.go, discovery/gossip_result_test.go, lnutils/context_test.go, and discovery/syncer_test.go, plus comment edits in discovery/gossiper_test.go. No production source files are modified. The removed comment in TestPrematureAnnouncementProcessing referenced a historical ‘Network Isolation Attack’ and a chan error -> actor.Promise migration, but the commit message frames this change as stale-comment cleanup, not as a security patch. There is no evidence in the diff of a vulnerability being fixed or introduced.
Changed components
Inspect captured patch +251 / −11
diff --git a/actor/future_test.go b/actor/future_test.go
index 3d56d2c..45d6314 100644
--- a/actor/future_test.go
+++ b/actor/future_test.go
@@ -431,9 +431,56 @@ func TestFutureOnCompleteFutureCompletes(t *testing.T) {
})
}
-// TestPromiseCompleteIdempotency verifies that calling Complete on a Promise
-// multiple times is safe and only the first completion takes effect. Subsequent
-// calls should return false and not alter the future's result.
+// TestCompleteWith verifies that CompleteWith resolves a promise with the
+// supplied value, that the resolution is immediately visible on the Future, and
+// that a second call is a safe no-op (idempotency inherited from Complete).
+func TestCompleteWith(t *testing.T) {
+ t.Parallel()
+
+ // Normal completion — value should be visible on the future.
+ promise := NewPromise[int]()
+ CompleteWith(promise, 42)
+
+ result := promise.Future().Await(context.Background())
+ require.False(t, result.IsErr())
+ result.WhenOk(func(v int) {
+ require.Equal(t, 42, v)
+ })
+
+ // Second call must be a no-op; the future must still hold 42.
+ CompleteWith(promise, 99)
+
+ result2 := promise.Future().Await(context.Background())
+ require.False(t, result2.IsErr())
+ result2.WhenOk(func(v int) {
+ require.Equal(t, 42, v, "second CompleteWith must not overwrite")
+ })
+}
+
+// TestAwaitFuture verifies that AwaitFuture unpacks a resolved future into a
+// (value, nil) pair and that context cancellation before resolution is
+// reported as a (zero, ctx.Err()) pair.
+func TestAwaitFuture(t *testing.T) {
+ t.Parallel()
+
+ // Resolved future — should return the value with a nil error.
+ promise := NewPromise[string]()
+ CompleteWith(promise, "hello")
+
+ val, err := AwaitFuture(context.Background(), promise.Future())
+ require.NoError(t, err)
+ require.Equal(t, "hello", val)
+
+ // Cancelled context — should return the zero value and ctx.Err().
+ unresolved := NewPromise[string]()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ val2, err2 := AwaitFuture(ctx, unresolved.Future())
+ require.ErrorIs(t, err2, context.Canceled)
+ require.Equal(t, "", val2, "zero value expected on cancellation")
+}
+
func TestPromiseCompleteIdempotency(t *testing.T) {
t.Parallel()
diff --git a/discovery/gossip_result_test.go b/discovery/gossip_result_test.go
new file mode 100644
index 0000000..c184877
--- /dev/null
+++ b/discovery/gossip_result_test.go
@@ -0,0 +1,72 @@
+package discovery
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAwaitGossipResultSuccess verifies that AwaitGossipResult returns nil
+// when the future resolves with a nil error.
+func TestAwaitGossipResultSuccess(t *testing.T) {
+ t.Parallel()
+
+ promise := actor.NewPromise[error]()
+ actor.CompleteWith(promise, (error)(nil))
+
+ err := AwaitGossipResult(t.Context(), promise.Future())
+ require.NoError(t, err)
+}
+
+// TestAwaitGossipResultError verifies that AwaitGossipResult returns the
+// underlying gossip processing error when the future resolves with one.
+func TestAwaitGossipResultError(t *testing.T) {
+ t.Parallel()
+
+ sentinel := errors.New("gossip validation failed")
+ promise := actor.NewPromise[error]()
+ actor.CompleteWith(promise, sentinel)
+
+ err := AwaitGossipResult(t.Context(), promise.Future())
+ require.ErrorIs(t, err, sentinel)
+}
+
+// TestAwaitGossipResultContextCancelled verifies that AwaitGossipResult
+// returns the context error when the context is cancelled before the future
+// resolves.
+func TestAwaitGossipResultContextCancelled(t *testing.T) {
+ t.Parallel()
+
+ // A promise that is never completed simulates a gossiper that has
+ // shut down before producing a result.
+ promise := actor.NewPromise[error]()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel() // Cancel immediately.
+
+ err := AwaitGossipResult(ctx, promise.Future())
+ require.ErrorIs(t, err, context.Canceled)
+}
+
+// TestCompleteGossipResultIdempotent verifies that completeGossipResult can be
+// called multiple times without blocking. The second call must be a no-op.
+func TestCompleteGossipResultIdempotent(t *testing.T) {
+ t.Parallel()
+
+ sentinel := errors.New("processing error")
+ promise := actor.NewPromise[error]()
+
+ // First completion sets the result.
+ completeGossipResult(promise, sentinel)
+
+ // Second completion with a different value must be a no-op and must
+ // never block.
+ completeGossipResult(promise, nil)
+
+ // The future should still contain the original sentinel error.
+ err := AwaitGossipResult(t.Context(), promise.Future())
+ require.ErrorIs(t, err, sentinel)
+}
diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go
index f4bffc5..1995326 100644
--- a/discovery/gossiper_test.go
+++ b/discovery/gossiper_test.go
@@ -4242,7 +4242,9 @@ func TestRateLimitChannelUpdates(t *testing.T) {
for i := uint32(0); i < uint32(tCtx.gossiper.cfg.MaxChannelUpdateBurst); i++ { //nolint:ll
updateSameDirection.Timestamp++
updateSameDirection.BaseFee++
- require.NoError(t, signUpdate(remoteKeyPriv1, &updateSameDirection))
+ require.NoError(
+ t, signUpdate(remoteKeyPriv1, &updateSameDirection),
+ )
assertRateLimit(&updateSameDirection, nodePeer1, false)
}
@@ -5071,11 +5073,7 @@ func TestGossipSyncerRace(t *testing.T) {
// TestPrematureAnnouncementProcessing checks that a channel announcement
// carrying a future block height is correctly deferred via isPremature and
-// then re-processed once the target block arrives — without deadlocking the
-// gossiper. This is a regression test for the Network Isolation Attack where
-// a premature announcement could block the gossiper by sending to an already-
-// full chan error twice. actor.Promise.Complete is idempotent via sync.Once,
-// so the second completion is a safe no-op.
+// then re-processed once the target block arrives.
func TestPrematureAnnouncementProcessing(t *testing.T) {
t.Parallel()
@@ -5103,8 +5101,7 @@ func TestPrematureAnnouncementProcessing(t *testing.T) {
// Advance the block height to 200. This triggers resendFutureMessages,
// which re-queues the cached announcement copy into the processing
- // pipeline. The copy carries a fresh actor.Promise whose Complete call
- // is idempotent — unlike chan error, a second completion never blocks.
+ // pipeline.
tCtx.notifier.notifyBlock(chainhash.Hash{}, futureHeight)
// Wait for the announcement to be broadcast. This confirms the gossiper
diff --git a/discovery/syncer_test.go b/discovery/syncer_test.go
index 368fb22..7385886 100644
--- a/discovery/syncer_test.go
+++ b/discovery/syncer_test.go
@@ -2339,6 +2339,62 @@ func TestGossipSyncerSyncTransitions(t *testing.T) {
}
}
+// TestProcessSyncTransitionShutdown asserts that ProcessSyncTransition
+// surfaces a syncer shutdown that occurs while it is awaiting the syncer's
+// reply as the historical ErrGossipSyncerExiting sentinel, rather than the
+// raw context.Canceled error from the bridge context. This locks in the
+// pre-actor.Future error contract for callers using errors.Is to detect
+// shutdown.
+func TestProcessSyncTransitionShutdown(t *testing.T) {
+ t.Parallel()
+
+ // Spin up a syncer that is in chansSynced so it is willing to accept
+ // a transition request, but DON'T call Start so the syncer's
+ // channelGraphSyncer goroutine will never drain syncTransitionReqs.
+ // This deterministically forces ProcessSyncTransition into the await
+ // path with no chance of the request being processed before we close
+ // the syncer's quit channel.
+ _, syncer, _ := newTestSyncer(
+ lnwire.ShortChannelID{BlockHeight: latestKnownHeight},
+ defaultEncoding, defaultChunkSize,
+ )
+ syncer.setSyncState(chansSynced)
+ syncer.setSyncType(PassiveSync)
+
+ // Buffer the request channel so the enqueue select succeeds without
+ // any consumer present, mirroring how the gossip syncer is wired in
+ // production (syncTransitionReqs is unbuffered there, but here we
+ // only need the enqueue arm to win).
+ syncer.syncTransitionReqs = make(chan *syncTransitionReq, 1)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- syncer.ProcessSyncTransition(ActiveSync)
+ }()
+
+ // Give the goroutine a moment to enqueue the request and enter the
+ // await path. We deliberately wait longer than syncTransitionTimeout
+ // to prove the await is no longer bounded by it.
+ select {
+ case err := <-errCh:
+ t.Fatalf("ProcessSyncTransition returned early before "+
+ "shutdown: %v", err)
+ case <-time.After(syncTransitionTimeout + 100*time.Millisecond):
+ }
+
+ // Now signal the syncer's quit and assert that the await unblocks
+ // with the historical sentinel.
+ syncer.cg.Quit()
+
+ select {
+ case err := <-errCh:
+ require.ErrorIs(t, err, ErrGossipSyncerExiting)
+ case <-time.After(time.Second):
+ t.Fatal("ProcessSyncTransition did not return after syncer " +
+ "shutdown")
+ }
+}
+
// TestGossipSyncerHistoricalSync tests that a gossip syncer can perform a
// historical sync with the remote peer.
func TestGossipSyncerHistoricalSync(t *testing.T) {
diff --git a/lnutils/context_test.go b/lnutils/context_test.go
new file mode 100644
index 0000000..85092fb
--- /dev/null
+++ b/lnutils/context_test.go
@@ -0,0 +1,68 @@
+package lnutils
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestContextFromQuitQuitCancels verifies that closing the quit channel
+// cancels the derived context.
+func TestContextFromQuitQuitCancels(t *testing.T) {
+ t.Parallel()
+
+ quit := make(chan struct{})
+ ctx, cancel := ContextFromQuit(quit)
+ defer cancel()
+
+ // The context should not be done yet.
+ select {
+ case <-ctx.Done():
+ t.Fatal("context cancelled before quit was closed")
+ default:
+ }
+
+ // Closing the quit channel should cancel the context.
+ close(quit)
+
+ select {
+ case <-ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("context was not cancelled after quit was closed")
+ }
+
+ require.ErrorIs(t, ctx.Err(), context.Canceled)
+}
+
+// TestContextFromQuitCancelCleansUp verifies that calling the returned cancel
+// function cancels the context and allows the internal goroutine to exit
+// cleanly, preventing a goroutine leak.
+func TestContextFromQuitCancelCleansUp(t *testing.T) {
+ t.Parallel()
+
+ // Use a quit channel that is never closed to ensure the goroutine
+ // exits via the cancel path, not the quit path.
+ quit := make(chan struct{})
+ ctx, cancel := ContextFromQuit(quit)
+
+ // The context should not be done yet.
+ select {
+ case <-ctx.Done():
+ t.Fatal("context cancelled before cancel was called")
+ default:
+ }
+
+ // Calling cancel should cancel the context. The internal goroutine
+ // exits via <-ctx.Done().
+ cancel()
+
+ select {
+ case <-ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("context was not cancelled after cancel() was called")
+ }
+
+ require.ErrorIs(t, ctx.Err(), context.Canceled)
+}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.