mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-26 17:43:21 +01:00
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.
This commit is contained in:
@@ -67,7 +67,8 @@ 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
|
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
|
HideLoginNotice bool // Hide the Erupe notice on login
|
||||||
LoginNotices []string // MHFML string of the login notices displayed
|
LoginNotices []string // MHFML string of the login notices displayed
|
||||||
PatchServerManifest string // Manifest patch server override
|
PatchServerManifest string // Manifest patch server override
|
||||||
@@ -353,6 +354,7 @@ func registerDefaults() {
|
|||||||
viper.SetDefault("CommandPrefix", "!")
|
viper.SetDefault("CommandPrefix", "!")
|
||||||
viper.SetDefault("AutoCreateAccount", true)
|
viper.SetDefault("AutoCreateAccount", true)
|
||||||
viper.SetDefault("LoopDelay", 50)
|
viper.SetDefault("LoopDelay", 50)
|
||||||
|
viper.SetDefault("ShutdownCountdownSeconds", 10)
|
||||||
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})
|
||||||
|
|
||||||
|
|||||||
13
main.go
13
main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -393,8 +394,12 @@ func main() {
|
|||||||
<-sig
|
<-sig
|
||||||
|
|
||||||
if !config.DisableSoftCrash {
|
if !config.DisableSoftCrash {
|
||||||
for i := 0; i < 10; i++ {
|
countdown := config.ShutdownCountdownSeconds
|
||||||
message := fmt.Sprintf("Shutting down in %d...", 10-i)
|
if countdown <= 0 {
|
||||||
|
countdown = 10
|
||||||
|
}
|
||||||
|
for i := 0; i < countdown; i++ {
|
||||||
|
message := fmt.Sprintf("Shutting down in %d...", countdown-i)
|
||||||
for _, c := range channels {
|
for _, c := range channels {
|
||||||
c.BroadcastChatMessage(message)
|
c.BroadcastChatMessage(message)
|
||||||
}
|
}
|
||||||
@@ -409,8 +414,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.Channel.Enabled {
|
if config.Channel.Enabled {
|
||||||
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer drainCancel()
|
||||||
for _, c := range channels {
|
for _, c := range channels {
|
||||||
c.Shutdown()
|
c.ShutdownAndDrain(drainCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ func TestParseSmallNotImplemented(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
// MHF packets - NOT IMPLEMENTED
|
// MHF packets - NOT IMPLEMENTED
|
||||||
{"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}},
|
{"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}},
|
||||||
{"MsgMhfAddRewardSongCount", &MsgMhfAddRewardSongCount{}},
|
|
||||||
{"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}},
|
{"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}},
|
||||||
{"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}},
|
{"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}},
|
||||||
{"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}},
|
{"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}},
|
||||||
|
|||||||
@@ -87,13 +87,17 @@ func TestHandleMsgMhfAddRewardSongCount(t *testing.T) {
|
|||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
defer func() {
|
pkt := &mhfpacket.MsgMhfAddRewardSongCount{AckHandle: 42}
|
||||||
if r := recover(); r != nil {
|
handleMsgMhfAddRewardSongCount(session, pkt)
|
||||||
t.Errorf("handleMsgMhfAddRewardSongCount panicked: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
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) {
|
func TestHandleMsgMhfAcquireMonthlyReward(t *testing.T) {
|
||||||
@@ -202,7 +206,6 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fn func()
|
fn func()
|
||||||
}{
|
}{
|
||||||
{"handleMsgMhfAddRewardSongCount", func() { handleMsgMhfAddRewardSongCount(session, nil) }},
|
|
||||||
{"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }},
|
{"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package channelserver
|
package channelserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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() {
|
func (s *Server) acceptClients() {
|
||||||
for {
|
for {
|
||||||
conn, err := s.listener.Accept()
|
conn, err := s.listener.Accept()
|
||||||
|
|||||||
Reference in New Issue
Block a user