feat(shutdown): passive drain and rename DisableSoftCrash

Shutdown now proceeds in three phases: close listeners immediately on
signal, broadcast the in-game countdown, wait up to ShutdownDrainSeconds
(default 30) for sessions to disconnect naturally via DrainPassive, then
force-close any stragglers. This prevents players from entering new
quests after the countdown starts, and lets mid-quest sessions finish
saving without being killed mid-write. A second SIGINT during passive
drain cancels it so the force-close phase runs immediately.

Renamed DisableSoftCrash -> DisableShutdownCountdown since the flag
controls the countdown, not crash behaviour. Existing config.json files
keep working via a Viper alias on the legacy key.

Closes #179.
This commit is contained in:
Houmgaor
2026-04-06 19:32:35 +02:00
parent 9b0f735335
commit e48d33ca76
7 changed files with 134 additions and 64 deletions

39
main.go
View File

@@ -14,8 +14,8 @@ import (
"syscall"
"time"
cfg "erupe-ce/config"
"erupe-ce/common/gametime"
cfg "erupe-ce/config"
"erupe-ce/server/api"
"erupe-ce/server/channelserver"
"erupe-ce/server/discordbot"
@@ -401,7 +401,15 @@ func main() {
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
if !config.DisableSoftCrash {
// Phase 1: stop accepting new connections immediately so players seeing
// the countdown cannot start fresh quests at T-1.
if config.Channel.Enabled {
for _, c := range channels {
c.Shutdown()
}
}
if !config.DisableShutdownCountdown {
countdown := config.ShutdownCountdownSeconds
if countdown <= 0 {
countdown = 10
@@ -422,6 +430,31 @@ func main() {
}
if config.Channel.Enabled {
// Phase 2: passive drain — give active sessions (mid-quest players)
// up to ShutdownDrainSeconds to disconnect on their own.
drainSecs := config.ShutdownDrainSeconds
if drainSecs < 0 {
drainSecs = 0
}
if drainSecs > 0 {
passiveCtx, passiveCancel := context.WithTimeout(context.Background(), time.Duration(drainSecs)*time.Second)
// A second signal cancels the passive drain so the force-close
// phase runs immediately.
go func() {
select {
case <-sig:
logger.Info("Second signal received, skipping passive drain")
passiveCancel()
case <-passiveCtx.Done():
}
}()
for _, c := range channels {
c.DrainPassive(passiveCtx)
}
passiveCancel()
}
// Phase 3: force-close any stragglers so logoutPlayer runs and saves.
drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer drainCancel()
for _, c := range channels {
@@ -451,7 +484,7 @@ func wait() {
}
func preventClose(config *cfg.Config, text string) {
if config != nil && config.DisableSoftCrash {
if config != nil && config.DisableShutdownCountdown {
os.Exit(0)
}
fmt.Println("\nFailed to start Erupe:\n" + text)