8a94ffd58f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
6.1 KiB
Go
181 lines
6.1 KiB
Go
package dispatch
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
|
)
|
|
|
|
// hangGuard implements a belt-and-suspenders hang-detection guardrail for
|
|
// ARK Survival Ascended (ark-sa) instances running under Wine 9.
|
|
// The Wine 9 RCON listener can wedge under sustained traffic; the engine
|
|
// also sometimes emits a "!!!HANG DETECTED!!!" line on its own.
|
|
// This guard auto-restarts the docker container in either case, with a
|
|
// cooldown to prevent flapping.
|
|
// The root-cause fix (docker_exec_rcon adapter) shipped 2026-05-03; this
|
|
// guard handles the rare residual cases.
|
|
// It replaces the bash watchdog that previously ran on princess via systemd timer.
|
|
|
|
const (
|
|
hangGuardWindow = 10 * time.Minute
|
|
hangGuardFailThreshold = 5
|
|
hangGuardCooldown = 10 * time.Minute
|
|
hangGuardEngineMarker = "!!!HANG DETECTED!!!"
|
|
)
|
|
|
|
// hangGuardState holds per-instance hang-detection state.
|
|
// Embed by value in instanceRecord; zero value is ready to use.
|
|
type hangGuardState struct {
|
|
mu sync.Mutex
|
|
consecutiveFails int
|
|
windowStart time.Time
|
|
lastRestart time.Time
|
|
restarting atomic.Bool
|
|
}
|
|
|
|
// arkHangGuardOnLogLine checks for engine-side hang detection tokens
|
|
// in console log lines. Only applies to ark-sa instances.
|
|
func (d *Dispatcher) arkHangGuardOnLogLine(rec *instanceRecord, line string) {
|
|
if rec.ModuleID != "ark-sa" {
|
|
return
|
|
}
|
|
if !strings.Contains(line, hangGuardEngineMarker) {
|
|
return
|
|
}
|
|
// Engine self-detected a hang; trigger restart asynchronously.
|
|
go d.arkHangGuardRestart(rec, fmt.Sprintf("engine emitted '%s'", hangGuardEngineMarker))
|
|
}
|
|
|
|
// arkHangGuardOnRconResult tracks RCON call successes/failures for
|
|
// hang detection. A threshold of consecutive failures triggers a restart.
|
|
func (d *Dispatcher) arkHangGuardOnRconResult(rec *instanceRecord, success bool) {
|
|
if rec.ModuleID != "ark-sa" {
|
|
return
|
|
}
|
|
|
|
rec.hangGuard.mu.Lock()
|
|
defer rec.hangGuard.mu.Unlock()
|
|
|
|
if success {
|
|
rec.hangGuard.consecutiveFails = 0
|
|
rec.hangGuard.windowStart = time.Time{}
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
if rec.hangGuard.windowStart.IsZero() || now.Sub(rec.hangGuard.windowStart) > hangGuardWindow {
|
|
// Start a new window
|
|
rec.hangGuard.windowStart = now
|
|
rec.hangGuard.consecutiveFails = 1
|
|
} else {
|
|
rec.hangGuard.consecutiveFails++
|
|
}
|
|
|
|
if rec.hangGuard.consecutiveFails >= hangGuardFailThreshold {
|
|
go d.arkHangGuardRestart(rec, fmt.Sprintf("%d RCON failures in last %v", rec.hangGuard.consecutiveFails, hangGuardWindow))
|
|
}
|
|
}
|
|
|
|
// arkHangGuardRestart performs the actual container restart, respecting a
|
|
// cooldown and using a single-flight pattern to prevent concurrent restarts.
|
|
func (d *Dispatcher) arkHangGuardRestart(rec *instanceRecord, reason string) {
|
|
// Single-flight: only one restart at a time per instance.
|
|
if !rec.hangGuard.restarting.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
defer rec.hangGuard.restarting.Store(false)
|
|
|
|
// Lock only for cooldown check and counter reset. We do NOT set
|
|
// lastRestart here — only after a successful runtime.Restart, so a
|
|
// failed restart attempt doesn't lock out retries for the full
|
|
// cooldown (would block the next legitimate hang signal). Resetting
|
|
// consecutiveFails / windowStart up front is fine: the bounce is
|
|
// either about to happen (success path will set lastRestart) or has
|
|
// failed (caller logged + emitted; future signals will re-arm).
|
|
rec.hangGuard.mu.Lock()
|
|
if !rec.hangGuard.lastRestart.IsZero() && time.Since(rec.hangGuard.lastRestart) < hangGuardCooldown {
|
|
remaining := hangGuardCooldown - time.Since(rec.hangGuard.lastRestart)
|
|
d.log.Debug("hang-guard skipped (cooldown)",
|
|
"instance_id", rec.InstanceID,
|
|
"cooldown_remaining", remaining,
|
|
)
|
|
rec.hangGuard.mu.Unlock()
|
|
return
|
|
}
|
|
rec.hangGuard.consecutiveFails = 0
|
|
rec.hangGuard.windowStart = time.Time{}
|
|
rec.hangGuard.mu.Unlock()
|
|
|
|
d.log.Warn("hang-guard restarting container",
|
|
"instance_id", rec.InstanceID,
|
|
"container_id", rec.ContainerID,
|
|
"reason", reason,
|
|
)
|
|
|
|
// Emit a panel log line announcing the restart.
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: rec.InstanceID,
|
|
Stream: "stderr",
|
|
At: timestamppb.Now(),
|
|
Line: "[panel] HANG-GUARD: " + reason,
|
|
}},
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
// 2026-05-25: bumped grace 5s → 30s. The hang-guard's bounce can
|
|
// fire mid-SaveWorld; the 5s window made it a near-guarantee that the
|
|
// SIGKILL would land inside the SQLite-backed save's flush — same
|
|
// torn-save pattern that the memory guardrail was producing. 30s lets
|
|
// most in-flight saves finish before the engine dies; if the engine is
|
|
// truly wedged it'll get SIGKILL'd after the 30s anyway. See
|
|
// stats.go's emergencyStopGracePeriod for the matching rationale.
|
|
if err := d.runtime.Restart(ctx, rec.ContainerID, 30*time.Second); err != nil {
|
|
d.log.Error("hang-guard restart failed",
|
|
"instance_id", rec.InstanceID,
|
|
"err", err,
|
|
)
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: rec.InstanceID,
|
|
Stream: "stderr",
|
|
At: timestamppb.Now(),
|
|
Line: "[panel] HANG-GUARD restart failed: " + err.Error(),
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Successful restart — start the cooldown clock so we don't bounce
|
|
// the same instance again for hangGuardCooldown. A failed restart
|
|
// (above branch returned early) intentionally leaves lastRestart
|
|
// untouched so the next hang signal can retry.
|
|
rec.hangGuard.mu.Lock()
|
|
rec.hangGuard.lastRestart = time.Now()
|
|
rec.hangGuard.mu.Unlock()
|
|
|
|
d.log.Info("hang-guard restart issued",
|
|
"instance_id", rec.InstanceID,
|
|
)
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: rec.InstanceID,
|
|
Stream: "stderr",
|
|
At: timestamppb.Now(),
|
|
Line: "[panel] HANG-GUARD restart issued",
|
|
}},
|
|
})
|
|
}
|