diff options
| author | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
| commit | 7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch) | |
| tree | 902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/game/economy.go | |
| parent | 153acb7f644d755c45533b7c5311e3ffa47e8973 (diff) | |
| download | thehouseoficarus-7eab582d8a8ee2af1a6baae7baf0696c7005de2e.tar.gz | |
fix: harden triggers, economy, casino, world data, and admin/net paths
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 68 |
1 files changed, 68 insertions, 0 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index b436a29..3505a86 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -43,6 +43,7 @@ package game import ( "bufio" + "bytes" "crypto/rand" "encoding/hex" "encoding/json" @@ -113,6 +114,12 @@ type economyJournal struct { // does) or a crash can lose the whole journal — including the record of // a debit that is already durable in the character file. dirSynced bool + // tailChecked records whether the journal tail has been verified to end on + // a newline before the first append of this process. A torn final line + // (short write) must be truncated before a new record is appended, or the + // two merge into one malformed mid-file line that permanently aborts + // recovery. + tailChecked bool // stakes indexes outstanding wager records per player identity key // (economyKey: the character's stable ID when known, else its name). // All journal mutations happen on the game owner (or during startup @@ -166,6 +173,12 @@ func (j *economyJournal) append(rec economyRecord) error { if err != nil { return err } + if !j.tailChecked { + if err := j.repairTornTail(); err != nil { + return fmt.Errorf("repair economy journal tail: %w", err) + } + j.tailChecked = true + } f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("open economy journal: %w", err) @@ -186,6 +199,61 @@ func (j *economyJournal) append(rec economyRecord) error { return nil } +// repairTornTail guarantees the journal ends on a newline before a new record +// is appended. A short write (ENOSPC, a flaky filesystem) can leave a partial +// JSON suffix; appending a full record after it merges the two into one +// malformed mid-file line, which loadFolded treats as fatal and which then +// permanently disables recovery and compaction. Truncating the torn suffix is +// safe: a partial record was never a committed transaction. This runs at most +// once per process (append is otherwise hot). +func (j *economyJournal) repairTornTail() error { + f, err := os.OpenFile(j.path, os.O_RDWR, 0600) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + size := fi.Size() + if size == 0 { + return nil + } + + last := make([]byte, 1) + if _, err := f.ReadAt(last, size-1); err != nil { + return err + } + if last[0] == '\n' { + return nil + } + + // Scan backwards in bounded chunks for the last newline and truncate the + // torn suffix after it. If none exists the whole file is one torn line. + const chunk = 64 * 1024 + buf := make([]byte, chunk) + for end := size; end > 0; { + start := end - chunk + if start < 0 { + start = 0 + } + n := int(end - start) + if _, err := f.ReadAt(buf[:n], start); err != nil { + return err + } + if idx := bytes.LastIndexByte(buf[:n], '\n'); idx >= 0 { + return f.Truncate(start + int64(idx) + 1) + } + end = start + } + return f.Truncate(0) +} + // appendRetry appends one durable line, retrying a bounded number of // times: a transient write failure must not permanently lose a marker // that money correctness depends on. |
