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

@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### 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`. - 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`.

View File

@@ -2,7 +2,9 @@
"Host": "127.0.0.1", "Host": "127.0.0.1",
"BinPath": "bin", "BinPath": "bin",
"Language": "en", "Language": "en",
"DisableSoftCrash": false, "DisableShutdownCountdown": false,
"ShutdownCountdownSeconds": 10,
"ShutdownDrainSeconds": 30,
"HideLoginNotice": true, "HideLoginNotice": true,
"LoginNotices": [ "LoginNotices": [
"<BODY><CENTER><SIZE_3><C_4>Welcome to Erupe SU9.3!<BR><BODY><LEFT><SIZE_2><C_5>Erupe is experimental software<C_7>, we are not liable for any<BR><BODY>issues caused by installing the software!<BR><BODY><BR><BODY><C_4>■Report bugs on Discord!<C_7><BR><BODY><BR><BODY><C_4>■Test everything!<C_7><BR><BODY><BR><BODY><C_4>■Don't talk to softlocking NPCs!<C_7><BR><BODY><BR><BODY><C_4>■Fork the code on GitHub!<C_7><BR><BODY><BR><BODY>Thank you to all of the contributors,<BR><BODY><BR><BODY>this wouldn't exist without you." "<BODY><CENTER><SIZE_3><C_4>Welcome to Erupe SU9.3!<BR><BODY><LEFT><SIZE_2><C_5>Erupe is experimental software<C_7>, we are not liable for any<BR><BODY>issues caused by installing the software!<BR><BODY><BR><BODY><C_4>■Report bugs on Discord!<C_7><BR><BODY><BR><BODY><C_4>■Test everything!<C_7><BR><BODY><BR><BODY><C_4>■Don't talk to softlocking NPCs!<C_7><BR><BODY><BR><BODY><C_4>■Fork the code on GitHub!<C_7><BR><BODY><BR><BODY>Thank you to all of the contributors,<BR><BODY><BR><BODY>this wouldn't exist without you."

View File

@@ -64,30 +64,31 @@ func (m Mode) String() string {
// Config holds the global server-wide config. // Config holds the global server-wide config.
type Config struct { type Config struct {
Host string `mapstructure:"Host"` Host string `mapstructure:"Host"`
BinPath string `mapstructure:"BinPath"` BinPath string `mapstructure:"BinPath"`
Language string Language string
DisableSoftCrash bool // Disables the 'Press Return to exit' dialog allowing scripts to reboot the server automatically 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 DisableSoftCrash is true) ShutdownCountdownSeconds int // Seconds to count down before shutting down (default 10; ignored when DisableShutdownCountdown is true)
HideLoginNotice bool // Hide the Erupe notice on login ShutdownDrainSeconds int // Additional seconds to wait for sessions to disconnect naturally before force-closing (default 30)
LoginNotices []string // MHFML string of the login notices displayed HideLoginNotice bool // Hide the Erupe notice on login
PatchServerManifest string // Manifest patch server override LoginNotices []string // MHFML string of the login notices displayed
PatchServerFile string // File patch server override PatchServerManifest string // Manifest patch server override
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion PatchServerFile string // File patch server override
DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers) DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
ClientMode string DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
RealClientMode Mode ClientMode string
QuestCacheExpiry int // Number of seconds to keep quest data cached RealClientMode Mode
CommandPrefix string // The prefix for commands QuestCacheExpiry int // Number of seconds to keep quest data cached
AutoCreateAccount bool // Automatically create accounts if they don't exist CommandPrefix string // The prefix for commands
LoopDelay int // Delay in milliseconds between each loop iteration AutoCreateAccount bool // Automatically create accounts if they don't exist
DefaultCourses []uint16 LoopDelay int // Delay in milliseconds between each loop iteration
EarthStatus int32 DefaultCourses []uint16
EarthID int32 EarthStatus int32
EarthMonsters []int32 EarthID int32
SaveDumps SaveDumpOptions EarthMonsters []int32
Screenshots ScreenshotsOptions SaveDumps SaveDumpOptions
Capture CaptureOptions Screenshots ScreenshotsOptions
Capture CaptureOptions
DebugOptions DebugOptions DebugOptions DebugOptions
GameplayOptions GameplayOptions GameplayOptions GameplayOptions
@@ -356,6 +357,11 @@ func registerDefaults() {
viper.SetDefault("AutoCreateAccount", true) viper.SetDefault("AutoCreateAccount", true)
viper.SetDefault("LoopDelay", 50) viper.SetDefault("LoopDelay", 50)
viper.SetDefault("ShutdownCountdownSeconds", 10) 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("DefaultCourses", []uint16{1, 23, 24})
viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0}) 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 // TestConfigStruct tests Config structure creation with all fields
func TestConfigStruct(t *testing.T) { func TestConfigStruct(t *testing.T) {
cfg := &Config{ cfg := &Config{
Host: "localhost", Host: "localhost",
BinPath: "/opt/erupe", BinPath: "/opt/erupe",
Language: "en", Language: "en",
DisableSoftCrash: false, DisableShutdownCountdown: false,
HideLoginNotice: false, HideLoginNotice: false,
LoginNotices: []string{"Welcome"}, LoginNotices: []string{"Welcome"},
PatchServerManifest: "http://patch.example.com/manifest", PatchServerManifest: "http://patch.example.com/manifest",
PatchServerFile: "http://patch.example.com/files", PatchServerFile: "http://patch.example.com/files",
DeleteOnSaveCorruption: false, DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false, DisableSaveIntegrityCheck: false,
ClientMode: "ZZ", ClientMode: "ZZ",
RealClientMode: ZZ, RealClientMode: ZZ,
QuestCacheExpiry: 3600, QuestCacheExpiry: 3600,
CommandPrefix: "!", CommandPrefix: "!",
AutoCreateAccount: false, AutoCreateAccount: false,
LoopDelay: 100, LoopDelay: 100,
DefaultCourses: []uint16{1, 2, 3}, DefaultCourses: []uint16{1, 2, 3},
EarthStatus: 0, EarthStatus: 0,
EarthID: 0, EarthID: 0,
EarthMonsters: []int32{100, 101, 102}, EarthMonsters: []int32{100, 101, 102},
SaveDumps: SaveDumpOptions{ SaveDumps: SaveDumpOptions{
Enabled: true, Enabled: true,
RawEnabled: false, RawEnabled: false,

View File

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

39
main.go
View File

@@ -14,8 +14,8 @@ import (
"syscall" "syscall"
"time" "time"
cfg "erupe-ce/config"
"erupe-ce/common/gametime" "erupe-ce/common/gametime"
cfg "erupe-ce/config"
"erupe-ce/server/api" "erupe-ce/server/api"
"erupe-ce/server/channelserver" "erupe-ce/server/channelserver"
"erupe-ce/server/discordbot" "erupe-ce/server/discordbot"
@@ -401,7 +401,15 @@ func main() {
signal.Notify(sig, os.Interrupt, syscall.SIGTERM) signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig <-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 countdown := config.ShutdownCountdownSeconds
if countdown <= 0 { if countdown <= 0 {
countdown = 10 countdown = 10
@@ -422,6 +430,31 @@ func main() {
} }
if config.Channel.Enabled { 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) drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer drainCancel() defer drainCancel()
for _, c := range channels { for _, c := range channels {
@@ -451,7 +484,7 @@ func wait() {
} }
func preventClose(config *cfg.Config, text string) { func preventClose(config *cfg.Config, text string) {
if config != nil && config.DisableSoftCrash { if config != nil && config.DisableShutdownCountdown {
os.Exit(0) os.Exit(0)
} }
fmt.Println("\nFailed to start Erupe:\n" + text) fmt.Println("\nFailed to start Erupe:\n" + text)

View File

@@ -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 // ShutdownAndDrain stops accepting new connections, force-closes every active
// session so that their logoutPlayer cleanup runs (saves character data, removes // session so that their logoutPlayer cleanup runs (saves character data, removes
// from stages, etc.), then waits until all sessions have been removed from the // from stages, etc.), then waits until all sessions have been removed from the