diff --git a/cmd/wavecli/waveclicommands/cmd_vtxos.go b/cmd/wavecli/waveclicommands/cmd_vtxos.go index f31f4f493..5c9e7563f 100644 --- a/cmd/wavecli/waveclicommands/cmd_vtxos.go +++ b/cmd/wavecli/waveclicommands/cmd_vtxos.go @@ -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) diff --git a/cmd/wavecli/waveclicommands/cmd_vtxos_join_test.go b/cmd/wavecli/waveclicommands/cmd_vtxos_join_test.go new file mode 100644 index 000000000..2fe548cd1 --- /dev/null +++ b/cmd/wavecli/waveclicommands/cmd_vtxos_join_test.go @@ -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") +} diff --git a/round/actor.go b/round/actor.go index a281cc4eb..54608407e 100644 --- a/round/actor.go +++ b/round/actor.go @@ -4,6 +4,7 @@ package round import ( "context" "encoding/hex" + "errors" "fmt" "log/slog" "maps" @@ -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 @@ -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 } diff --git a/round/actor_nopending_test.go b/round/actor_nopending_test.go new file mode 100644 index 000000000..bceb7a257 --- /dev/null +++ b/round/actor_nopending_test.go @@ -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) +} diff --git a/waved/rpc_server.go b/waved/rpc_server.go index 96b3c75b0..6794fd3bc 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -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) }