Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions cmd/wavecli/waveclicommands/cmd_vtxos.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,12 +669,27 @@ func maybeJoinNextRound(cmd *cobra.Command, client waverpc.DaemonServiceClient,
decision := decideAutoJoin(dryRun, noJoin)

if decision.Join {
if _, err := client.JoinNextRound(
resp, err := client.JoinNextRound(
cmd.Context(), &waverpc.JoinNextRoundRequest{},
); err != nil {
)
if err != nil {
return fmt.Errorf("auto-join next round failed: %w",
err)
}

// A refresh or leave that queued nothing (e.g. --all with no
// live VTXOs) leaves no pending round to join; the daemon
// reports this benign no-op rather than failing, so say so
// plainly instead of the ordinary auto-join notice, which
// would wrongly imply a round was joined.
if resp.GetStatus() == "nothing_to_join" {
fmt.Fprintln(
cmd.ErrOrStderr(),
"nothing queued to join",
)

return nil
}
}

fmt.Fprintln(cmd.ErrOrStderr(), decision.Notice)
Expand Down
68 changes: 68 additions & 0 deletions cmd/wavecli/waveclicommands/cmd_vtxos_join_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package waveclicommands

import (
"bytes"
"context"
"testing"

"github.com/lightninglabs/wavelength/waverpc"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)

// joinStubClient is a minimal DaemonServiceClient that implements only
// JoinNextRound with a canned response; every other method is nil and
// would panic if called, which these tests never do.
type joinStubClient struct {
waverpc.DaemonServiceClient

status string
calls int
}

func (c *joinStubClient) JoinNextRound(_ context.Context,
_ *waverpc.JoinNextRoundRequest, _ ...grpc.CallOption) (
*waverpc.JoinNextRoundResponse, error) {

c.calls++

return &waverpc.JoinNextRoundResponse{Status: c.status}, nil
}

// TestMaybeJoinNextRoundNothingToJoin pins that when the daemon reports a
// benign "nothing_to_join" (an auto-join after a refresh/leave that queued
// nothing), the CLI succeeds and says so plainly rather than printing the
// ordinary joined notice — the user-facing half of the fix that stops an
// empty --all from surfacing an INTERNAL round-join error.
func TestMaybeJoinNextRoundNothingToJoin(t *testing.T) {
t.Parallel()

var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetErr(&stderr)

client := &joinStubClient{status: "nothing_to_join"}

err := maybeJoinNextRound(cmd, client, false /* dryRun */, false)
require.NoError(t, err)
require.Equal(t, 1, client.calls)
require.Contains(t, stderr.String(), "nothing queued to join")
}

// TestMaybeJoinNextRoundJoined pins that an ordinary join still prints the
// auto-join notice and does not misreport it as a no-op.
func TestMaybeJoinNextRoundJoined(t *testing.T) {
t.Parallel()

var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetErr(&stderr)

client := &joinStubClient{status: "joined"}

err := maybeJoinNextRound(cmd, client, false /* dryRun */, false)
require.NoError(t, err)
require.Equal(t, 1, client.calls)
require.NotContains(t, stderr.String(), "nothing queued to join")
}
12 changes: 11 additions & 1 deletion round/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package round
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"maps"
Expand Down Expand Up @@ -2026,6 +2027,14 @@ func (a *RoundClientActor) routeServerMessageByRoundID(ctx context.Context,
), true
}

// ErrNoPendingRound is returned when a server message — most notably an
// IntentRequested join trigger — arrives with no pending round to route
// it to. It is deliberately typed rather than a bare fmt.Errorf so
// callers can tell this benign "nothing to join" shape (an auto-join
// after a refresh or leave that queued nothing) apart from a genuine
// internal fault, and report it as a no-op instead of an error.
var ErrNoPendingRound = errors.New("no pending round")

// routeServerMessageToPending dispatches a non-RoundID-keyed server
// message to a pending (temp-keyed) round. Returns (fsm, _, false)
// when an FSM was found; otherwise returns (_, errResult, true) so
Expand Down Expand Up @@ -2090,7 +2099,8 @@ func (a *RoundClientActor) routeServerMessageToPending(ctx context.Context,

if roundFSM == nil {
return nil, fn.Err[actormsg.RoundActorResp](
fmt.Errorf("no pending round for event %T", msg),
fmt.Errorf("no pending round for event %T: %w", msg,
ErrNoPendingRound),
), true
}

Expand Down
26 changes: 26 additions & 0 deletions round/actor_nopending_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package round

import (
"testing"

"github.com/stretchr/testify/require"
)

// TestRouteServerMessageNoPendingRoundTyped pins that a server message
// delivered with no pending round to route it to — the shape an
// IntentRequested join trigger takes when nothing was queued — fails
// with the typed ErrNoPendingRound rather than a bare error. JoinNextRound
// relies on errors.Is against this sentinel to turn the benign "nothing
// queued to join" case into a no-op instead of an INTERNAL fault.
func TestRouteServerMessageNoPendingRoundTyped(t *testing.T) {
t.Parallel()

h := newActorTestHarness(t)

result := h.receive(&ServerMessageNotification{
Message: &IntentRequested{},
})

require.Error(t, result.Err())
require.ErrorIs(t, result.Err(), ErrNoPendingRound)
}
16 changes: 16 additions & 0 deletions waved/rpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2747,6 +2747,22 @@ func (r *RPCServer) JoinNextRound(ctx context.Context,
}

if err := r.server.TriggerRoundRegistration(ctx); err != nil {
// An IntentRequested with nothing queued is a benign no-op,
// not an internal fault: this is exactly the shape an
// auto-join after an empty refresh/leave selection produces.
// Report it as a clean "nothing to join" status so the caller
// (and the CLI's auto-join step) treats it as the no-op it is
// instead of a confusing INTERNAL error.
if errors.Is(err, round.ErrNoPendingRound) {
r.server.log.InfoS(
ctx, "JoinNextRound: nothing queued to join",
)

return &waverpc.JoinNextRoundResponse{
Status: "nothing_to_join",
}, nil
}

return nil, status.Errorf(codes.Internal, "join next round: %v",
err)
}
Expand Down
Loading