mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +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]
|
## [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`.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
39
main.go
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user