From e48d33ca76307a523bb4602d6c75e77d645d88b5 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 6 Apr 2026 19:32:35 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 5 ++ config.reference.json | 4 +- config/config.go | 54 ++++++++++++---------- config/config_load_test.go | 36 +++++++-------- config/config_test.go | 36 +++++++-------- main.go | 39 ++++++++++++++-- server/channelserver/sys_channel_server.go | 24 ++++++++++ 7 files changed, 134 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bdd19a1..0994c72da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Shutdown now proceeds in three phases: listeners close immediately on signal, then a passive drain waits up to `ShutdownDrainSeconds` (default 30) for sessions to disconnect naturally, then remaining connections are force-closed. This prevents players from starting new quests after the countdown begins ([#179](https://github.com/Mezeporta/Erupe/issues/179)). +- Renamed config field `DisableSoftCrash` → `DisableShutdownCountdown` for clarity. The old key is still accepted via a Viper alias so existing `config.json` files keep working without modification ([#179](https://github.com/Mezeporta/Erupe/issues/179)). + ### Added - Reverse-engineered user binary data types from `mhfo-hd.dll` via Ghidra: type 1 = character name (max 17B SJIS), type 2 = player profile with self-introduction (208B), type 3 = equipment/appearance snapshot (384B). Added structured parsing with size validation warnings to `handleMsgSysSetUserBinary`. diff --git a/config.reference.json b/config.reference.json index 678370d6f..d59b37e3a 100644 --- a/config.reference.json +++ b/config.reference.json @@ -2,7 +2,9 @@ "Host": "127.0.0.1", "BinPath": "bin", "Language": "en", - "DisableSoftCrash": false, + "DisableShutdownCountdown": false, + "ShutdownCountdownSeconds": 10, + "ShutdownDrainSeconds": 30, "HideLoginNotice": true, "LoginNotices": [ "
Welcome to Erupe SU9.3!
Erupe is experimental software, we are not liable for any
issues caused by installing the software!

■Report bugs on Discord!

■Test everything!

■Don't talk to softlocking NPCs!

■Fork the code on GitHub!

Thank you to all of the contributors,

this wouldn't exist without you." diff --git a/config/config.go b/config/config.go index a006614bb..8e6ed2ca5 100644 --- a/config/config.go +++ b/config/config.go @@ -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}) diff --git a/config/config_load_test.go b/config/config_load_test.go index e88e3bf7a..48ede18d7 100644 --- a/config/config_load_test.go +++ b/config/config_load_test.go @@ -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, diff --git a/config/config_test.go b/config/config_test.go index 65951fd6c..912c41461 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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, diff --git a/main.go b/main.go index 4082cebcc..8ec45b372 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index a20fc0bf9..c87547102 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -249,6 +249,30 @@ func (s *Server) Shutdown() { } +// DrainPassive waits for active sessions to disconnect naturally (e.g. players +// finishing a quest and logging out) without force-closing anything. Returns +// when the session count reaches zero or ctx is cancelled. Callers should have +// already invoked Shutdown() to close the listener so no new sessions arrive. +func (s *Server) DrainPassive(ctx context.Context) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + s.Lock() + n := len(s.sessions) + s.Unlock() + if n == 0 { + s.logger.Info("Passive drain complete: all sessions disconnected") + return + } + select { + case <-ctx.Done(): + s.logger.Info("Passive drain deadline reached", zap.Int("remaining_sessions", n)) + return + case <-ticker.C: + } + } +} + // ShutdownAndDrain stops accepting new connections, force-closes every active // session so that their logoutPlayer cleanup runs (saves character data, removes // from stages, etc.), then waits until all sessions have been removed from the