mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
In this commit, we extend TestAwaitFuture to cover the case where the future is completed with an fn.Err result. The existing test only exercised the fn.Ok (success) and context cancellation paths. The new case calls promise.Complete(fn.Err[string](sentinel)) directly and verifies that AwaitFuture surfaces the error as the second return value while returning the zero string value in the first, which is the documented contract for Result[T].Unpack().
525 lines
14 KiB
Go
525 lines
14 KiB
Go
package actor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lightningnetwork/lnd/fn/v2"
|
|
"github.com/stretchr/testify/require"
|
|
"pgregory.net/rapid"
|
|
)
|
|
|
|
// TestFutureAwaitContextCancellation tests that Await respects context
|
|
// cancellation if the context is cancelled before the future resolves.
|
|
func TestFutureAwaitContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
// Test cancellation when the Await context is cancelled via
|
|
// context.Cancel. The underlying future will not be completed, allowing
|
|
// us to test the cancellation path of Await.
|
|
prom1 := NewPromise[int]()
|
|
fut1 := prom1.Future()
|
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
|
|
|
// We'll cancel the future immediately after creating it.
|
|
cancel1()
|
|
|
|
result1 := fut1.Await(ctx1)
|
|
|
|
require.True(t, result1.IsErr())
|
|
require.ErrorIs(
|
|
t, result1.Err(), context.Canceled,
|
|
"await with immediate cancel",
|
|
)
|
|
|
|
// Test cancellation when the Await context times out. The
|
|
// underlying future will also not be completed.
|
|
prom2 := NewPromise[int]()
|
|
fut2 := prom2.Future()
|
|
|
|
// Use a very short timeout that will trigger.
|
|
ctx2, cancel2 := context.WithTimeout(
|
|
context.Background(), 1*time.Nanosecond,
|
|
)
|
|
defer cancel2()
|
|
|
|
// Await the future; it should fall through to the timeout
|
|
// because the future itself is not completed.
|
|
result2 := fut2.Await(ctx2)
|
|
|
|
require.True(t, result2.IsErr())
|
|
require.ErrorIs(
|
|
t, result2.Err(), context.DeadlineExceeded,
|
|
"await with timeout",
|
|
)
|
|
})
|
|
}
|
|
|
|
// TestFutureAwaitFutureCompletes tests that Await returns the future's
|
|
// result if the context is not cancelled before the future resolves.
|
|
func TestFutureAwaitFutureCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
valToSet := rapid.Int().Draw(t, "valToSet")
|
|
|
|
// With a 50% chance, configure the test to complete the future
|
|
// with an error instead of a successful value.
|
|
var errToSet error
|
|
if rapid.Bool().Draw(t, "have_error") {
|
|
errToSet = fmt.Errorf("err")
|
|
}
|
|
|
|
promise := NewPromise[int]()
|
|
fut := promise.Future()
|
|
|
|
// Use a background context for Await, as we expect the future
|
|
// to complete normally.
|
|
ctx := context.Background()
|
|
|
|
// Complete the future in a separate goroutine to simulate an
|
|
// asynchronous operation.
|
|
go func() {
|
|
if errToSet != nil {
|
|
promise.Complete(fn.Err[int](errToSet))
|
|
} else {
|
|
promise.Complete(fn.Ok(valToSet))
|
|
}
|
|
}()
|
|
|
|
// Now we'll wait for the future to complete, then verify below
|
|
// that the result (value or error) is as expected.
|
|
result := fut.Await(ctx)
|
|
|
|
if errToSet != nil {
|
|
// If an error was set, verify that Await returns that
|
|
// specific error.
|
|
require.True(t, result.IsErr())
|
|
require.ErrorIs(
|
|
t, result.Err(), errToSet,
|
|
"await with error",
|
|
)
|
|
} else {
|
|
// If no error was set, verify that Await returns the
|
|
// correct value.
|
|
require.False(t, result.IsErr(), "await with value")
|
|
|
|
result.WhenOk(func(val int) {
|
|
require.Equal(
|
|
t, valToSet, val, "await with value",
|
|
)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFutureThenApplyContextCancellation tests that ThenApply respects its
|
|
// context, yielding a context error if cancelled before the original future
|
|
// completes.
|
|
func TestFutureThenApplyContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
// The original future will not be completed in this test case,
|
|
// allowing us to specifically test the cancellation behavior of
|
|
// the context passed to ThenApply.
|
|
originalPromise := NewPromise[int]()
|
|
originalFut := originalPromise.Future()
|
|
|
|
// Create a context for ThenApply and cancel it immediately.
|
|
ctxApply, cancelApply := context.WithCancel(
|
|
context.Background(),
|
|
)
|
|
cancelApply()
|
|
|
|
var transformCalled atomic.Bool
|
|
transform := func(i int) int {
|
|
transformCalled.Store(true)
|
|
return i * 2
|
|
}
|
|
|
|
// Register the transformation. The ThenApply operation itself
|
|
// will start a goroutine to await the originalFut.
|
|
newFut := originalFut.ThenApply(ctxApply, transform)
|
|
|
|
// Await the new (transformed) future. Use a background context
|
|
// for this Await to isolate the test to the cancellation of
|
|
// ctxApply.
|
|
result := newFut.Await(context.Background())
|
|
|
|
require.True(t, result.IsErr())
|
|
require.ErrorIs(
|
|
t, result.Err(), context.Canceled,
|
|
"ThenApply with cancelled context",
|
|
)
|
|
require.False(
|
|
t, transformCalled.Load(),
|
|
"ThenApply transform function called despite "+
|
|
"context cancellation",
|
|
)
|
|
})
|
|
}
|
|
|
|
// TestFutureThenApplyOriginalFutureCompletes tests ThenApply's behavior when
|
|
// the original future completes (with a value or error) before ThenApply's
|
|
// context is cancelled.
|
|
func TestFutureThenApplyOriginalFutureCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
initialVal := rapid.Int().Draw(t, "initialVal")
|
|
|
|
// Configure whether the original future completes with an error
|
|
// or a successful value.
|
|
var originalErr error
|
|
if rapid.Bool().Draw(t, "have_error") {
|
|
originalErr = fmt.Errorf("original error")
|
|
}
|
|
|
|
originalPromise := NewPromise[int]()
|
|
originalFut := originalPromise.Future()
|
|
|
|
// Create a context for ThenApply that should not cancel before
|
|
// the original future completes.
|
|
ctxApply, cancelApply := context.WithTimeout(
|
|
context.Background(), 50*time.Millisecond,
|
|
)
|
|
defer cancelApply()
|
|
|
|
var transformCalled atomic.Bool
|
|
transform := func(i int) int {
|
|
transformCalled.Store(true)
|
|
return i * 2
|
|
}
|
|
|
|
newFut := originalFut.ThenApply(ctxApply, transform)
|
|
|
|
// Complete the original future in a separate goroutine to
|
|
// simulate asynchrony.
|
|
go func() {
|
|
if originalErr != nil {
|
|
originalPromise.Complete(
|
|
fn.Err[int](originalErr),
|
|
)
|
|
} else {
|
|
originalPromise.Complete(fn.Ok(initialVal))
|
|
}
|
|
}()
|
|
|
|
// Await our new future which transforms the original future's
|
|
// result. Use a background context for this Await.
|
|
result := newFut.Await(context.Background())
|
|
|
|
if originalErr != nil {
|
|
// If the original future had an error, the transformed
|
|
// future should also yield that same error.
|
|
require.True(t, result.IsErr())
|
|
require.ErrorIs(
|
|
t, result.Err(), originalErr,
|
|
"ThenApply with original error",
|
|
)
|
|
require.False(
|
|
t, transformCalled.Load(),
|
|
"ThenApply transform function called despite "+
|
|
"original future having an error",
|
|
)
|
|
} else {
|
|
// If the original future completed successfully, the
|
|
// transformed future should contain the transformed value.
|
|
require.False(
|
|
t, result.IsErr(),
|
|
"ThenApply with original value",
|
|
)
|
|
require.True(
|
|
t, transformCalled.Load(),
|
|
"ThenApply transform function not called for "+
|
|
"successful original future",
|
|
)
|
|
|
|
result.WhenOk(func(val int) {
|
|
expectedTransformedVal := initialVal * 2
|
|
require.Equal(
|
|
t, expectedTransformedVal, val,
|
|
"ThenApply with original value",
|
|
)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFutureOnCompleteContextCancellation tests that OnComplete's callback
|
|
// receives a context error if its context is cancelled before the future
|
|
// completes.
|
|
func TestFutureOnCompleteContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
// The original future will not complete in this test, allowing
|
|
// us to focus on the cancellation of OnComplete's context.
|
|
originalPromise := NewPromise[int]()
|
|
originalFut := originalPromise.Future()
|
|
|
|
// Create a context for OnComplete and cancel it immediately to
|
|
// simulate a premature cancellation.
|
|
ctxComplete, cancelComplete := context.WithCancel(
|
|
context.Background(),
|
|
)
|
|
cancelComplete()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
var (
|
|
callbackInvoked atomic.Bool
|
|
callbackResultValue fn.Result[int]
|
|
|
|
// mu is a mutex to protect callbackResultValue as it's
|
|
// written by the callback goroutine and read by the
|
|
// test goroutine.
|
|
mu sync.Mutex
|
|
)
|
|
|
|
// Register an OnComplete callback. The callback itself runs in
|
|
// a new goroutine started by OnComplete.
|
|
originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) {
|
|
mu.Lock()
|
|
callbackResultValue = res
|
|
mu.Unlock()
|
|
|
|
callbackInvoked.Store(true)
|
|
wg.Done()
|
|
})
|
|
|
|
// Use a wait group and a channel to wait for the callback to
|
|
// be invoked.
|
|
waitChan := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(waitChan)
|
|
}()
|
|
|
|
select {
|
|
// The callback should be invoked, even if with a context error.
|
|
case <-waitChan:
|
|
case <-time.After(50 * time.Millisecond):
|
|
require.Fail(
|
|
t, "OnComplete callback timed out waiting "+
|
|
"for execution after context cancel",
|
|
)
|
|
}
|
|
|
|
require.True(
|
|
t, callbackInvoked.Load(),
|
|
"OnComplete callback not invoked",
|
|
)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Verify that the callback received a context.Canceled error
|
|
// because its context (ctxComplete) was cancelled.
|
|
require.True(t, callbackResultValue.IsErr())
|
|
require.ErrorIs(
|
|
t, callbackResultValue.Err(), context.Canceled,
|
|
"OnComplete with cancelled context",
|
|
)
|
|
})
|
|
}
|
|
|
|
// TestFutureOnCompleteFutureCompletes tests OnComplete's behavior when the
|
|
// future completes (with value or error) before its context is cancelled.
|
|
func TestFutureOnCompleteFutureCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rapid.Check(t, func(t *rapid.T) {
|
|
valToSet := rapid.Int().Draw(t, "valToSet")
|
|
|
|
// Configure whether the original future completes with an error
|
|
// or a successful value.
|
|
var originalErr error
|
|
if rapid.Bool().Draw(t, "have_error") {
|
|
originalErr = fmt.Errorf("original error")
|
|
}
|
|
|
|
originalPromise := NewPromise[int]()
|
|
originalFut := originalPromise.Future()
|
|
|
|
// Use a background context for OnComplete, as we expect the
|
|
// future to complete normally.
|
|
ctxComplete := context.Background()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
|
|
var (
|
|
callbackInvoked atomic.Bool
|
|
callbackResultValue fn.Result[int]
|
|
mu sync.Mutex
|
|
)
|
|
|
|
// Register an OnComplete callback. This callback will execute
|
|
// once the originalFut completes.
|
|
originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) {
|
|
mu.Lock()
|
|
callbackResultValue = res
|
|
mu.Unlock()
|
|
|
|
callbackInvoked.Store(true)
|
|
|
|
wg.Done()
|
|
})
|
|
|
|
// Complete the original future in a separate goroutine to
|
|
// simulate an asynchronous operation.
|
|
go func() {
|
|
if originalErr != nil {
|
|
originalPromise.Complete(
|
|
fn.Err[int](originalErr),
|
|
)
|
|
} else {
|
|
originalPromise.Complete(fn.Ok(valToSet))
|
|
}
|
|
}()
|
|
|
|
// Use a wait group and a channel to wait for the callback's
|
|
// execution.
|
|
waitChan := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(waitChan)
|
|
}()
|
|
|
|
select {
|
|
// The callback should be invoked as the future completes.
|
|
case <-waitChan:
|
|
case <-time.After(50 * time.Millisecond):
|
|
require.Fail(
|
|
t, "OnComplete callback timed out waiting "+
|
|
"for execution",
|
|
)
|
|
}
|
|
|
|
require.True(t, callbackInvoked.Load())
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Verify that the callback received the correct result (either
|
|
// the error or the value from the completed future).
|
|
if originalErr != nil {
|
|
require.True(t, callbackResultValue.IsErr())
|
|
require.ErrorIs(
|
|
t, callbackResultValue.Err(), originalErr,
|
|
"OnComplete with error",
|
|
)
|
|
} else {
|
|
require.False(
|
|
t, callbackResultValue.IsErr(),
|
|
"OnComplete with value",
|
|
)
|
|
callbackResultValue.WhenOk(func(val int) {
|
|
require.Equal(
|
|
t, valToSet, val,
|
|
"OnComplete with value",
|
|
)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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, that a future completed with fn.Err is reported as a
|
|
// (zero, err) 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)
|
|
|
|
// Future completed with fn.Err — should surface the error as the
|
|
// second return value with the zero string value.
|
|
sentinel := fmt.Errorf("result-level error")
|
|
errPromise := NewPromise[string]()
|
|
errPromise.Complete(fn.Err[string](sentinel))
|
|
|
|
val3, err3 := AwaitFuture(context.Background(), errPromise.Future())
|
|
require.ErrorIs(t, err3, sentinel)
|
|
require.Equal(t, "", val3, "zero value expected on fn.Err result")
|
|
|
|
// 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()
|
|
|
|
promise := NewPromise[string]()
|
|
future := promise.Future()
|
|
|
|
// First completion should succeed.
|
|
firstResult := fn.Ok("first-value")
|
|
ok := promise.Complete(firstResult)
|
|
require.True(t, ok, "first Complete should return true")
|
|
|
|
// Second completion with a different value should be ignored.
|
|
secondResult := fn.Ok("second-value")
|
|
ok = promise.Complete(secondResult)
|
|
require.False(t, ok, "second Complete should return false")
|
|
|
|
// Third completion with an error should also be ignored.
|
|
thirdResult := fn.Err[string](fmt.Errorf("should be ignored"))
|
|
ok = promise.Complete(thirdResult)
|
|
require.False(t, ok, "third Complete should return false")
|
|
|
|
// The future should contain the first value.
|
|
result := future.Await(context.Background())
|
|
require.False(t, result.IsErr(), "future should not be an error")
|
|
result.WhenOk(func(val string) {
|
|
require.Equal(
|
|
t, "first-value", val,
|
|
"future should contain the first completion value",
|
|
)
|
|
})
|
|
}
|