From 87d08f0151e643951769e323995f1c5ed89ee200 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 01:50:17 -0400 Subject: fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths --- internal/engine/tick.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) (limited to 'internal/engine/tick.go') diff --git a/internal/engine/tick.go b/internal/engine/tick.go index aa10a73..68dd54f 100644 --- a/internal/engine/tick.go +++ b/internal/engine/tick.go @@ -26,6 +26,7 @@ type Engine struct { ticker *time.Ticker running bool stopCh chan struct{} + loopDone chan struct{} } func New() *Engine { @@ -67,8 +68,11 @@ func (e *Engine) Start(tickLengthMs int) { // Capture this run's resources. A quick Stop/Start must not make the // old goroutine consume the replacement ticker or stop channel. ticker, stopCh := e.ticker, e.stopCh + loopDone := make(chan struct{}) + e.loopDone = loopDone go func() { + defer close(loopDone) for { select { case <-ticker.C: @@ -100,10 +104,17 @@ func (e *Engine) Stop() { // exited. Use this for graceful shutdown, where the final tick must finish // before owned state is saved. func (e *Engine) StopAndWait() { + e.mu.Lock() + loopDone := e.loopDone + e.mu.Unlock() e.Stop() - // Join any in-flight tick: processTick holds tickMu for its whole body, - // so acquiring the mutex blocks until the running callback completes; - // holding it until return keeps that guarantee for the caller. + // Wait for the ticker loop goroutine to exit. This is the real save + // barrier: the loop may already have received from the ticker and be + // about to call processTick, which locking tickMu alone would not catch. + if loopDone != nil { + <-loopDone + } + // Also join a direct Tick() that is mid-callback on another goroutine. e.tickMu.Lock() defer e.tickMu.Unlock() } -- cgit v1.2.3