mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 06:13:50 +02:00
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:
@@ -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`.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"Host": "127.0.0.1",
|
||||
"BinPath": "bin",
|
||||
"Language": "en",
|
||||
"DisableSoftCrash": false,
|
||||
"DisableShutdownCountdown": false,
|
||||
"ShutdownCountdownSeconds": 10,
|
||||
"ShutdownDrainSeconds": 30,
|
||||
"HideLoginNotice": true,
|
||||
"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."
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
main.go
39
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user