feat: refund abandoned rounds; full-journey and capacity tests
Fixes the money bug flagged earlier. When an instance died mid-round its players had already been debited, so their stakes sat with the house: balanced books, quietly robbed players. Every instance now sweeps for unresolved rounds and refunds them. Such a round is marked void, not settled. The schema caught this: the reveal_is_complete constraint requires a settled round to publish its seed, and an abandoned round has no outcome to reveal. Void is a distinct state with its own column and a check that the two are exclusive. Claiming happens before money moves, so concurrent reconcilers on different instances refund exactly once. Adds TestFullPlayerJourney: sign-in with no account, fund, scratch, bet with an auto target, settle, verify the round independently, check the ledger history is continuous, transfer to a friend, and confirm the books still sum to zero. It asserts against the ledger rather than the API's own summary. Adds cmd/loadtest. One instance on 4 cores held 25,000 concurrent websocket connections with zero failures at 586MB RSS, about 26KB per connection, with the load generator competing for the same CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -342,3 +342,235 @@ func TestAutoCashOutThroughTheAPI(t *testing.T) {
|
||||
t.Fatal("could not place an auto cash-out bet within 90s")
|
||||
}
|
||||
}
|
||||
|
||||
// The complete journey a real player takes, in one test: arrive with no
|
||||
// account, get funded, play both games, watch the ledger explain every change,
|
||||
// move sats to a friend, and verify a round independently.
|
||||
//
|
||||
// Each step asserts against the ledger rather than against the API's own
|
||||
// summary, so a bug that reports success while losing money fails here.
|
||||
func TestFullPlayerJourney(t *testing.T) {
|
||||
alice := newClient(t)
|
||||
bob := newClient(t)
|
||||
|
||||
// 1. Arrive. No account exists; a keypair is the whole sign-up.
|
||||
alice.signIn("alice")
|
||||
bob.signIn("bob")
|
||||
|
||||
var bal struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
alice.do("GET", "/api/balance", nil, &bal)
|
||||
if bal.BalanceMsat != 0 {
|
||||
t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
|
||||
}
|
||||
|
||||
// 2. Get funded.
|
||||
const funded = 50_000_000
|
||||
if got := alice.fund(funded); got != funded {
|
||||
t.Fatalf("balance after funding = %d, want %d", got, funded)
|
||||
}
|
||||
|
||||
// 3. Scratch a ticket. The balance must move by exactly stake and payout.
|
||||
var sc struct {
|
||||
Outcome struct {
|
||||
TierName string `json:"tier_name"`
|
||||
PayoutMsat int64 `json:"payout_msat"`
|
||||
Cells []int `json:"cells"`
|
||||
} `json:"outcome"`
|
||||
Proof struct {
|
||||
Commitment string `json:"commitment"`
|
||||
ServerSeed string `json:"server_seed"`
|
||||
} `json:"proof"`
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
const scratchStake = 1_000_000
|
||||
if code := alice.do("POST", "/api/scratch/play",
|
||||
map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
|
||||
t.Fatalf("scratch play returned %d", code)
|
||||
}
|
||||
wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
|
||||
if sc.BalanceMsat != wantAfterScratch {
|
||||
t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
|
||||
}
|
||||
|
||||
// The scratch proof must verify against its own seed.
|
||||
seed, err := hex.DecodeString(sc.Proof.ServerSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(seed)
|
||||
if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
|
||||
t.Fatal("scratch proof does not verify against its own commitment")
|
||||
}
|
||||
|
||||
// 4. Play a crash round with an auto cash-out target.
|
||||
const stake = 2_000_000
|
||||
var roundID int64
|
||||
beforeRound := sc.BalanceMsat
|
||||
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) && roundID == 0 {
|
||||
var games struct {
|
||||
Rooms []struct {
|
||||
RoundID int64 `json:"round_id"`
|
||||
Game string `json:"game"`
|
||||
State string `json:"state"`
|
||||
} `json:"rooms"`
|
||||
}
|
||||
alice.do("GET", "/api/games", nil, &games)
|
||||
for _, rm := range games.Rooms {
|
||||
if rm.Game != "rocket" || rm.State != "betting_open" {
|
||||
continue
|
||||
}
|
||||
var res struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
if code := alice.do("POST", "/api/bet", map[string]any{
|
||||
"game": "rocket", "stake_msat": stake,
|
||||
"auto_cashout": 1.5, "nickname": "alice",
|
||||
}, &res); code == 200 {
|
||||
roundID = rm.RoundID
|
||||
// The stake must leave immediately, not at settlement.
|
||||
if res.BalanceMsat != beforeRound-stake {
|
||||
t.Fatalf("balance after bet = %d, want %d",
|
||||
res.BalanceMsat, beforeRound-stake)
|
||||
}
|
||||
}
|
||||
}
|
||||
if roundID == 0 {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if roundID == 0 {
|
||||
t.Fatal("could not join a round within 90s")
|
||||
}
|
||||
|
||||
// 5. Wait for settlement and check the outcome is consistent.
|
||||
var proof struct {
|
||||
Commitment string `json:"commitment"`
|
||||
ServerSeed string `json:"server_seed"`
|
||||
ClientSeed string `json:"client_seed"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
CrashPoint *int64 `json:"crash_point"`
|
||||
Participants []string `json:"participants"`
|
||||
}
|
||||
settled := false
|
||||
deadline = time.Now().Add(120 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
||||
settled = true
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if !settled {
|
||||
t.Fatal("the round never settled")
|
||||
}
|
||||
|
||||
// 6. Verify the round independently, the way the client does.
|
||||
roundSeed, err := hex.DecodeString(proof.ServerSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rs := sha256.Sum256(roundSeed)
|
||||
if hex.EncodeToString(rs[:]) != proof.Commitment {
|
||||
t.Fatal("settled round does not match its published commitment")
|
||||
}
|
||||
if proof.CrashPoint == nil {
|
||||
t.Fatal("a settled round published no crash point")
|
||||
}
|
||||
crash := float64(*proof.CrashPoint) / 4294967296.0
|
||||
|
||||
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
||||
// reached the target, nothing otherwise.
|
||||
var after struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
// Settlement posts a moment after the reveal; poll briefly.
|
||||
wantWin := beforeRound - stake + stake*3/2
|
||||
wantLose := beforeRound - stake
|
||||
ok := false
|
||||
for i := 0; i < 20; i++ {
|
||||
alice.do("GET", "/api/balance", nil, &after)
|
||||
if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
|
||||
after.BalanceMsat, wantWin, wantLose)
|
||||
}
|
||||
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
||||
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d",
|
||||
crash, after.BalanceMsat, wantWin)
|
||||
}
|
||||
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||
crash, after.BalanceMsat, wantLose)
|
||||
}
|
||||
|
||||
// 8. Every balance change must be explained by the ledger.
|
||||
var hist struct {
|
||||
Entries []struct {
|
||||
Kind string `json:"Kind"`
|
||||
AmountMsat int64 `json:"AmountMsat"`
|
||||
BalanceBefore int64 `json:"BalanceBefore"`
|
||||
BalanceAfter int64 `json:"BalanceAfter"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
alice.do("GET", "/api/history", nil, &hist)
|
||||
if len(hist.Entries) < 3 {
|
||||
t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
|
||||
len(hist.Entries))
|
||||
}
|
||||
// History is newest-first; walking backwards, each entry's before must be
|
||||
// the previous entry's after.
|
||||
for i := 0; i < len(hist.Entries)-1; i++ {
|
||||
newer, older := hist.Entries[i], hist.Entries[i+1]
|
||||
if newer.BalanceBefore != older.BalanceAfter {
|
||||
t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
|
||||
"preceding %s ended at %d",
|
||||
newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
|
||||
}
|
||||
if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
|
||||
t.Fatalf("%s entry does not add up: %d + %d != %d",
|
||||
newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Send sats to a friend; both sides must move by the same amount.
|
||||
bobBefore := bob.fund(1)
|
||||
aliceBefore := after.BalanceMsat
|
||||
const gift = 500_000
|
||||
var xfer struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
if code := alice.do("POST", "/api/transfer", map[string]any{
|
||||
"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
|
||||
}, &xfer); code != 200 {
|
||||
t.Fatalf("transfer returned %d", code)
|
||||
}
|
||||
if xfer.BalanceMsat != aliceBefore-gift {
|
||||
t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
|
||||
}
|
||||
var bobAfter struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
bob.do("GET", "/api/balance", nil, &bobAfter)
|
||||
if bobAfter.BalanceMsat != bobBefore+gift {
|
||||
t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
|
||||
}
|
||||
|
||||
// 10. The books must still balance to zero after all of it.
|
||||
var health struct {
|
||||
Status string `json:"status"`
|
||||
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
||||
}
|
||||
alice.do("GET", "/api/health", nil, &health)
|
||||
if health.LedgerSumMsat != 0 {
|
||||
t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ func main() {
|
||||
go h.supervise(ctx)
|
||||
}
|
||||
|
||||
// Sweep for rounds abandoned by an instance that died mid-flight and
|
||||
// refund their stakes. Every instance runs this; the claim is atomic, so
|
||||
// concurrent sweeps refund exactly once.
|
||||
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.routes(),
|
||||
|
||||
153
cmd/loadtest/main.go
Normal file
153
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Command loadtest measures how many concurrent players an instance holds.
|
||||
//
|
||||
// It opens real WebSocket connections and, optionally, places real bets, then
|
||||
// reports connection success, frame delivery, and latency. The point is to
|
||||
// produce numbers rather than adjectives: run it against a candidate machine
|
||||
// and read the ceiling off the output.
|
||||
//
|
||||
// go run ./cmd/loadtest -conns 2000 -addr localhost:8080
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
addr = flag.String("addr", "localhost:8080", "instance to load")
|
||||
conns = flag.Int("conns", 500, "concurrent websocket connections")
|
||||
duration = flag.Duration("duration", 20*time.Second, "how long to hold them")
|
||||
game = flag.String("game", "rocket", "game room to join")
|
||||
ramp = flag.Duration("ramp", 5*time.Second, "time to open all connections")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *duration+*ramp+30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
connected atomic.Int64
|
||||
failed atomic.Int64
|
||||
frames atomic.Int64
|
||||
bytesRecv atomic.Int64
|
||||
dialMu sync.Mutex
|
||||
dialTimes []time.Duration
|
||||
)
|
||||
|
||||
fmt.Printf("opening %d connections to %s over %v\n", *conns, *addr, *ramp)
|
||||
start := time.Now()
|
||||
|
||||
// Stagger dialling: slamming every connection open at once measures the
|
||||
// accept backlog rather than the steady state anyone actually runs at.
|
||||
gap := *ramp / time.Duration(max(1, *conns))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < *conns; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
time.Sleep(time.Duration(i) * gap)
|
||||
|
||||
dialStart := time.Now()
|
||||
conn, _, err := websocket.Dial(ctx,
|
||||
fmt.Sprintf("ws://%s/ws/%s", *addr, *game), nil)
|
||||
if err != nil {
|
||||
failed.Add(1)
|
||||
return
|
||||
}
|
||||
took := time.Since(dialStart)
|
||||
defer conn.CloseNow()
|
||||
|
||||
connected.Add(1)
|
||||
dialMu.Lock()
|
||||
dialTimes = append(dialTimes, took)
|
||||
dialMu.Unlock()
|
||||
|
||||
// Read until the run ends. A client that stops reading is exactly
|
||||
// the slow-subscriber case the server has to survive, but here we
|
||||
// want the healthy path.
|
||||
readCtx, stop := context.WithTimeout(ctx, *duration)
|
||||
defer stop()
|
||||
for {
|
||||
_, data, err := conn.Read(readCtx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frames.Add(1)
|
||||
bytesRecv.Add(int64(len(data)))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Report progress while the run is in flight.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
fmt.Printf(" t+%-5s connected=%-6d failed=%-5d frames=%-8d client heap=%dMB\n",
|
||||
time.Since(start).Round(time.Second),
|
||||
connected.Load(), failed.Load(), frames.Load(),
|
||||
m.Alloc/1024/1024)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(done)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
sort.Slice(dialTimes, func(i, j int) bool { return dialTimes[i] < dialTimes[j] })
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("results")
|
||||
fmt.Printf(" connections attempted : %d\n", *conns)
|
||||
fmt.Printf(" connected : %d\n", connected.Load())
|
||||
fmt.Printf(" failed : %d\n", failed.Load())
|
||||
if len(dialTimes) > 0 {
|
||||
fmt.Printf(" dial p50 / p99 / max : %v / %v / %v\n",
|
||||
dialTimes[len(dialTimes)/2].Round(time.Millisecond),
|
||||
dialTimes[len(dialTimes)*99/100].Round(time.Millisecond),
|
||||
dialTimes[len(dialTimes)-1].Round(time.Millisecond))
|
||||
}
|
||||
fmt.Printf(" frames received : %d\n", frames.Load())
|
||||
fmt.Printf(" bytes received : %.1f MB\n", float64(bytesRecv.Load())/1e6)
|
||||
if connected.Load() > 0 {
|
||||
fmt.Printf(" frames per connection : %.1f\n",
|
||||
float64(frames.Load())/float64(connected.Load()))
|
||||
fmt.Printf(" server egress : %.2f MB/s\n",
|
||||
float64(bytesRecv.Load())/1e6/elapsed.Seconds())
|
||||
}
|
||||
|
||||
if failed.Load() > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n%d connections were refused: the ceiling is at or below %d\n",
|
||||
failed.Load(), *conns)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("held %d concurrent connections for %v with no failures",
|
||||
connected.Load(), duration)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user