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

View File

@@ -64,30 +64,31 @@ func (m Mode) String() string {
// Config holds the global server-wide config.
type Config struct {
Host string `mapstructure:"Host"`
BinPath string `mapstructure:"BinPath"`
Language string
DisableSoftCrash bool // Disables the 'Press Return to exit' dialog allowing scripts to reboot the server automatically
ShutdownCountdownSeconds int // Seconds to count down before shutting down (default 10; ignored when DisableSoftCrash is true)
HideLoginNotice bool // Hide the Erupe notice on login
LoginNotices []string // MHFML string of the login notices displayed
PatchServerManifest string // Manifest patch server override
PatchServerFile string // File patch server override
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
ClientMode string
RealClientMode Mode
QuestCacheExpiry int // Number of seconds to keep quest data cached
CommandPrefix string // The prefix for commands
AutoCreateAccount bool // Automatically create accounts if they don't exist
LoopDelay int // Delay in milliseconds between each loop iteration
DefaultCourses []uint16
EarthStatus int32
EarthID int32
EarthMonsters []int32
SaveDumps SaveDumpOptions
Screenshots ScreenshotsOptions
Capture CaptureOptions
Host string `mapstructure:"Host"`
BinPath string `mapstructure:"BinPath"`
Language string
DisableShutdownCountdown bool `mapstructure:"DisableShutdownCountdown"` // Skip the in-game shutdown countdown (lets scripts restart the server unattended). Previously named DisableSoftCrash — legacy key still accepted.
ShutdownCountdownSeconds int // Seconds to count down before shutting down (default 10; ignored when DisableShutdownCountdown is true)
ShutdownDrainSeconds int // Additional seconds to wait for sessions to disconnect naturally before force-closing (default 30)
HideLoginNotice bool // Hide the Erupe notice on login
LoginNotices []string // MHFML string of the login notices displayed
PatchServerManifest string // Manifest patch server override
PatchServerFile string // File patch server override
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
ClientMode string
RealClientMode Mode
QuestCacheExpiry int // Number of seconds to keep quest data cached
CommandPrefix string // The prefix for commands
AutoCreateAccount bool // Automatically create accounts if they don't exist
LoopDelay int // Delay in milliseconds between each loop iteration
DefaultCourses []uint16
EarthStatus int32
EarthID int32
EarthMonsters []int32
SaveDumps SaveDumpOptions
Screenshots ScreenshotsOptions
Capture CaptureOptions
DebugOptions DebugOptions
GameplayOptions GameplayOptions
@@ -356,6 +357,11 @@ func registerDefaults() {
viper.SetDefault("AutoCreateAccount", true)
viper.SetDefault("LoopDelay", 50)
viper.SetDefault("ShutdownCountdownSeconds", 10)
viper.SetDefault("ShutdownDrainSeconds", 30)
// Back-compat: old configs use DisableSoftCrash. RegisterAlias makes Viper
// treat reads/writes of the old key as the new key, so existing
// config.json files keep working without modification.
viper.RegisterAlias("DisableSoftCrash", "DisableShutdownCountdown")
viper.SetDefault("DefaultCourses", []uint16{1, 23, 24})
viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0})

View File

@@ -142,26 +142,26 @@ func TestLoadConfigDefaultModeWhenInvalid(t *testing.T) {
// TestConfigStruct tests Config structure creation with all fields
func TestConfigStruct(t *testing.T) {
cfg := &Config{
Host: "localhost",
BinPath: "/opt/erupe",
Language: "en",
DisableSoftCrash: false,
HideLoginNotice: false,
LoginNotices: []string{"Welcome"},
PatchServerManifest: "http://patch.example.com/manifest",
PatchServerFile: "http://patch.example.com/files",
Host: "localhost",
BinPath: "/opt/erupe",
Language: "en",
DisableShutdownCountdown: false,
HideLoginNotice: false,
LoginNotices: []string{"Welcome"},
PatchServerManifest: "http://patch.example.com/manifest",
PatchServerFile: "http://patch.example.com/files",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,
CommandPrefix: "!",
AutoCreateAccount: false,
LoopDelay: 100,
DefaultCourses: []uint16{1, 2, 3},
EarthStatus: 0,
EarthID: 0,
EarthMonsters: []int32{100, 101, 102},
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,
CommandPrefix: "!",
AutoCreateAccount: false,
LoopDelay: 100,
DefaultCourses: []uint16{1, 2, 3},
EarthStatus: 0,
EarthID: 0,
EarthMonsters: []int32{100, 101, 102},
SaveDumps: SaveDumpOptions{
Enabled: true,
RawEnabled: false,

View File

@@ -149,26 +149,26 @@ func TestGetOutboundIP4(t *testing.T) {
// TestConfigStructTypes verifies Config struct fields have correct types
func TestConfigStructTypes(t *testing.T) {
cfg := &Config{
Host: "localhost",
BinPath: "/path/to/bin",
Language: "en",
DisableSoftCrash: false,
HideLoginNotice: false,
LoginNotices: []string{"Notice"},
PatchServerManifest: "http://patch.example.com",
PatchServerFile: "http://files.example.com",
Host: "localhost",
BinPath: "/path/to/bin",
Language: "en",
DisableShutdownCountdown: false,
HideLoginNotice: false,
LoginNotices: []string{"Notice"},
PatchServerManifest: "http://patch.example.com",
PatchServerFile: "http://files.example.com",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,
CommandPrefix: "!",
AutoCreateAccount: false,
LoopDelay: 100,
DefaultCourses: []uint16{1, 2, 3},
EarthStatus: 1,
EarthID: 1,
EarthMonsters: []int32{1, 2, 3},
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,
CommandPrefix: "!",
AutoCreateAccount: false,
LoopDelay: 100,
DefaultCourses: []uint16{1, 2, 3},
EarthStatus: 1,
EarthID: 1,
EarthMonsters: []int32{1, 2, 3},
SaveDumps: SaveDumpOptions{
Enabled: true,
RawEnabled: false,