Skip to content

Commit 9f2bc2b

Browse files
Ryanmello07claude
andcommitted
fix(api): return [] not null for empty leaderboard and account points
A nil slice marshals as JSON `null`. The gomobile sdk binds these fields as pointers, so `null` becomes a nil object that the android client dereferences inside a jni callback -- the NPE cannot cross jni and ART aborts the whole process. Opening the Leaderboard tab hard-crashes the app on any deployment whose leaderboard query returns zero rows: a new or custom server, or one before its first payout cycle. GetLeaderboard's `earners` was a named return that was only ever appended to, so zero rows left it nil. Two further instances of the same class, both reproduced live against beta: /stats/leaderboard -> {"earners":null} /account/points -> {"account_points":null,"network_points":null} FetchAccountPoints has the identical shape and feeds `network_points` plus its `account_points` alias. And GetLeaderboard's controller left Earners nil on the *error* path, so a transient query error crashed the app just as an empty leaderboard did. Audited every model and controller function with a named slice return: of six with this shape, only these two are API-facing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg (cherry picked from commit 9af1951)
1 parent 9f2348c commit 9f2bc2b

4 files changed

Lines changed: 79 additions & 0 deletions

File tree

controller/leaderboard_controller.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ func GetLeaderboard(
2323
earners, err := model.GetLeaderboard(session.Ctx)
2424
if err != nil {
2525
return &model.LeaderboardResult{
26+
// Earners must be non-nil even on the error path: the client
27+
// deserialises the whole result before reading Error, and a nil
28+
// slice here marshals as `null`, which crashes the android client
29+
// exactly as an empty leaderboard did. A transient query error must
30+
// not take the app down.
31+
Earners: []model.Earner{},
2632
Error: &model.TopEarnersError{
2733
Message: err.Error(),
2834
},

model/account_point_model.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ func ApplyAccountPointsBatchInTx(
131131
}
132132

133133
func FetchAccountPoints(ctx context.Context, networkId server.Id) (accountPoints []AccountPoint) {
134+
// non-nil for the same reason as GetLeaderboard: a nil slice becomes JSON
135+
// `null`, which the gomobile binding turns into a nil pointer and the
136+
// android client crashes on. Feeds both `network_points` and its
137+
// `account_points` alias.
138+
accountPoints = []AccountPoint{}
134139

135140
server.Db(ctx, func(conn server.PgConn) {
136141
result, err := conn.Query(

model/leaderboard_model.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ type TopEarnersError struct {
2828
* Gets an ordered list of the top earners
2929
*/
3030
func GetLeaderboard(ctx context.Context) (earners []Earner, queryErr error) {
31+
// must be non-nil: a nil slice marshals as JSON `null`, and the gomobile
32+
// sdk binds this field as a pointer, so `null` becomes a nil object that
33+
// the android client dereferences inside a jni callback -- the NPE cannot
34+
// cross jni and ART aborts the whole process. An empty leaderboard is `[]`.
35+
earners = []Earner{}
3136

3237
// stats read: tolerates replica delay
3338
server.ReplicaDb(ctx, func(conn server.PgConn) {

model/nil_slice_json_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/urnetwork/server"
10+
)
11+
12+
// A nil slice marshals as JSON `null`. The gomobile sdk binds these fields as
13+
// pointers, so `null` becomes a nil object the android client dereferences
14+
// inside a jni callback; the NPE cannot cross jni and ART aborts the process.
15+
// An API slice that can legitimately be empty must serialise as `[]`.
16+
//
17+
// These assert on the marshalled bytes, because `[]` versus `null` is the
18+
// property that actually reaches the client.
19+
20+
func TestGetLeaderboardEmptyMarshalsAsArrayNotNull(t *testing.T) {
21+
server.DefaultTestEnv().Run(t, func(t testing.TB) {
22+
ctx := context.Background()
23+
24+
// a fresh db has no payouts, so the query returns zero rows -- the
25+
// exact condition that crashed the android client
26+
earners, err := GetLeaderboard(ctx)
27+
if err != nil {
28+
t.Fatalf("GetLeaderboard: %s", err)
29+
}
30+
if earners == nil {
31+
t.Fatal("GetLeaderboard returned a nil slice; it marshals as null and crashes the client")
32+
}
33+
34+
b, err := json.Marshal(LeaderboardResult{Earners: earners})
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
if !strings.Contains(string(b), `"earners":[]`) {
39+
t.Errorf("marshalled as %s, want \"earners\":[]", b)
40+
}
41+
})
42+
}
43+
44+
func TestFetchAccountPointsEmptyMarshalsAsArrayNotNull(t *testing.T) {
45+
server.DefaultTestEnv().Run(t, func(t testing.TB) {
46+
ctx := context.Background()
47+
48+
// a network with no points at all -- feeds both `network_points` and
49+
// its `account_points` alias
50+
points := FetchAccountPoints(ctx, server.NewId())
51+
if points == nil {
52+
t.Fatal("FetchAccountPoints returned a nil slice; it marshals as null and crashes the client")
53+
}
54+
55+
b, err := json.Marshal(map[string]any{"network_points": points})
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
if strings.Contains(string(b), "null") {
60+
t.Errorf("marshalled as %s, want an empty array", b)
61+
}
62+
})
63+
}

0 commit comments

Comments
 (0)