package game // Rollback-failure regressions for the durable money journal. When a journal // append fails, the invariant was historically "append failed means no record // bytes remain". A failed rollback (the truncate, or the sync that makes it // durable) breaks that invariant: the record can survive while the caller // believes the append failed. The wallet's journal-unavailable payout fallback // must then still record the attempted record's ID as open-payout evidence, or // recovery re-applies the surviving payout and pays the player twice. import ( "encoding/json" "errors" "os" "path/filepath" "testing" ) // TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails // the write with ENOSPC and the rollback truncate with EINVAL, so the append // must report that the record MAY have survived. func TestAppendRollbackFailureIsUncertain(t *testing.T) { if _, err := os.Stat("/dev/full"); err != nil { t.Skipf("/dev/full is not available: %v", err) } j := newEconomyJournal(t.TempDir()) j.path = "/dev/full" err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"}) if !errors.Is(err, errJournalAppendUncertain) { t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err) } } // TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal // append fails and whose rollback cannot be proven must leave open-payout // evidence in the character file. A surviving record is then settled from that // evidence at the next startup instead of being re-applied. func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) { g, p := newEvidenceHarness(t) // Break the journal append: point it at its own directory, which cannot // be opened for append or truncation. The wallet takes the file-only // fallback. journalPath := g.Economy.path g.Economy.path = filepath.Dir(journalPath) w := casinoWallet{g: g, p: p} if err := w.PayCredits(20); err != nil { t.Fatalf("fallback payout failed: %v", err) } if p.Credits != 110 { t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits) } if len(p.OpenPayouts) != 1 { t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v", len(p.OpenPayouts), p.OpenPayouts) } id := p.OpenPayouts[0] // Simulate the failed append having survived in the journal (the exact // state a failed rollback produces). Recovery must use the file evidence // and settle it, not re-apply it. g.Economy.path = journalPath rec := economyRecord{ ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true, Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110, } line, err := json.Marshal(rec) if err != nil { t.Fatal(err) } if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); err != nil { t.Fatal(err) } g.recoverEconomy() loaded, err := g.AccountStore.LoadCharacter(p.Name) if err != nil { t.Fatal(err) } if loaded.Credits != 110 { t.Fatalf("balance after recovery = %d, want 110 (surviving payout settled from evidence, not re-applied)", loaded.Credits) } if open := g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } }