From c43be3368025e194d556e8ee50741adb5eecf61c Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sat, 21 Mar 2026 01:36:31 +0100 Subject: [PATCH] feat(shutdown): graceful drain + configurable countdown Add ShutdownAndDrain to the channel server (issue #179 non-breaking subset): on SIGTERM/SIGINT, force-close all active sessions so that logoutPlayer runs for each one (saves character data, cleans up stages and semaphores), then poll until the session map empties or a 30-second context deadline passes. Existing Shutdown() is unchanged. Add ShutdownCountdownSeconds int config field (default 10) alongside DisableSoftCrash so operators can tune the broadcast countdown without patching code. A zero value falls back to 10 for safety. Fix pre-existing test failures: MsgMhfAddRewardSongCount has a complete Parse() implementation so it no longer belongs in the "NOT IMPLEMENTED" parse test list; its handler test is updated to pass a real packet and assert an ACK response instead of calling with nil. --- config/config.go | 4 +- main.go | 13 ++++-- network/mhfpacket/msg_parse_small_test.go | 1 - server/channelserver/handlers_reward_test.go | 17 +++++--- server/channelserver/sys_channel_server.go | 46 ++++++++++++++++++++ 5 files changed, 69 insertions(+), 12 deletions(-) diff --git a/config/config.go b/config/config.go index 8555fe701..b801f93d4 100644 --- a/config/config.go +++ b/config/config.go @@ -67,7 +67,8 @@ 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 + 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 @@ -353,6 +354,7 @@ func registerDefaults() { viper.SetDefault("CommandPrefix", "!") viper.SetDefault("AutoCreateAccount", true) viper.SetDefault("LoopDelay", 50) + viper.SetDefault("ShutdownCountdownSeconds", 10) viper.SetDefault("DefaultCourses", []uint16{1, 23, 24}) viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0}) diff --git a/main.go b/main.go index 0aee2a227..8014c85d4 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -393,8 +394,12 @@ func main() { <-sig if !config.DisableSoftCrash { - for i := 0; i < 10; i++ { - message := fmt.Sprintf("Shutting down in %d...", 10-i) + countdown := config.ShutdownCountdownSeconds + if countdown <= 0 { + countdown = 10 + } + for i := 0; i < countdown; i++ { + message := fmt.Sprintf("Shutting down in %d...", countdown-i) for _, c := range channels { c.BroadcastChatMessage(message) } @@ -409,8 +414,10 @@ func main() { } if config.Channel.Enabled { + drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer drainCancel() for _, c := range channels { - c.Shutdown() + c.ShutdownAndDrain(drainCtx) } } diff --git a/network/mhfpacket/msg_parse_small_test.go b/network/mhfpacket/msg_parse_small_test.go index b27524d16..36b005fd7 100644 --- a/network/mhfpacket/msg_parse_small_test.go +++ b/network/mhfpacket/msg_parse_small_test.go @@ -18,7 +18,6 @@ func TestParseSmallNotImplemented(t *testing.T) { }{ // MHF packets - NOT IMPLEMENTED {"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}}, - {"MsgMhfAddRewardSongCount", &MsgMhfAddRewardSongCount{}}, {"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}}, {"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}}, {"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}}, diff --git a/server/channelserver/handlers_reward_test.go b/server/channelserver/handlers_reward_test.go index 289e46640..a11fe515f 100644 --- a/server/channelserver/handlers_reward_test.go +++ b/server/channelserver/handlers_reward_test.go @@ -87,13 +87,17 @@ func TestHandleMsgMhfAddRewardSongCount(t *testing.T) { server := createMockServer() session := createMockSession(1, server) - defer func() { - if r := recover(); r != nil { - t.Errorf("handleMsgMhfAddRewardSongCount panicked: %v", r) - } - }() + pkt := &mhfpacket.MsgMhfAddRewardSongCount{AckHandle: 42} + handleMsgMhfAddRewardSongCount(session, pkt) - handleMsgMhfAddRewardSongCount(session, nil) + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Response packet should have data") + } + default: + t.Error("No response packet queued") + } } func TestHandleMsgMhfAcquireMonthlyReward(t *testing.T) { @@ -202,7 +206,6 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) { name string fn func() }{ - {"handleMsgMhfAddRewardSongCount", func() { handleMsgMhfAddRewardSongCount(session, nil) }}, {"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }}, } diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index bc49743ec..c919922a0 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -1,6 +1,7 @@ package channelserver import ( + "context" "encoding/binary" "errors" "fmt" @@ -246,6 +247,51 @@ func (s *Server) Shutdown() { } +// 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 +// sessions map or ctx is cancelled. It is safe to call multiple times. +func (s *Server) ShutdownAndDrain(ctx context.Context) { + s.Shutdown() + + // Snapshot all active connections while holding the lock, then close them + // outside the lock so we don't hold it during I/O. Closing a connection + // causes the session's recvLoop to see io.EOF and call logoutPlayer(), which + // in turn deletes the entry from s.sessions under the server mutex. + s.Lock() + conns := make([]net.Conn, 0, len(s.sessions)) + for conn := range s.sessions { + conns = append(conns, conn) + } + s.Unlock() + + for _, conn := range conns { + _ = conn.Close() + } + + // Poll until logoutPlayer has removed every session or the deadline passes. + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + s.Lock() + remaining := len(s.sessions) + s.Unlock() + s.logger.Warn("Shutdown drain timed out", zap.Int("remaining_sessions", remaining)) + return + case <-ticker.C: + s.Lock() + n := len(s.sessions) + s.Unlock() + if n == 0 { + s.logger.Info("Shutdown drain complete") + return + } + } + } +} + func (s *Server) acceptClients() { for { conn, err := s.listener.Accept()