feat(channelserver): decouple channel servers for independent operation (#33)

Enable multiple Erupe instances to share a single PostgreSQL database
without destroying each other's state, fix existing data races in
cross-channel access, and lay groundwork for future distributed
channel server deployments.

Phase 1 — DB safety:
- Scope DELETE FROM servers/sign_sessions to this instance's server IDs
- Fix ci++ bug where failed channel start shifted subsequent IDs

Phase 2 — Fix data races in cross-channel access:
- Lock sessions map in FindSessionByCharID and DisconnectUser
- Lock stagesLock in handleMsgSysLockGlobalSema
- Snapshot sessions/stages under lock in TransitMessage types 1-4
- Lock channel when finding mail notification targets

Phase 3 — ChannelRegistry interface:
- Define ChannelRegistry interface with 7 cross-channel operations
- Implement LocalChannelRegistry with proper locking
- Add SessionSnapshot/StageSnapshot immutable copy types
- Delegate WorldcastMHF, FindSessionByCharID, DisconnectUser to Registry
- Migrate LockGlobalSema and guild mail handlers to use Registry
- Add comprehensive tests including concurrent access

Phase 4 — Per-channel enable/disable:
- Add Enabled *bool to EntranceChannelInfo (nil defaults to true)
- Skip disabled channels in startup loop, preserving ID stability
- Add IsEnabled() helper with backward-compatible default
- Update config.example.json with Enabled field
This commit is contained in:
Houmgaor
2026-02-19 18:13:34 +01:00
parent ba9fce153d
commit 754b5a3bff
10 changed files with 661 additions and 79 deletions

View File

@@ -37,6 +37,7 @@ type userBinaryPartID struct {
type Server struct {
sync.Mutex
Channels []*Server
Registry ChannelRegistry
ID uint16
GlobalID string
IP string
@@ -271,6 +272,10 @@ func (s *Server) BroadcastMHF(pkt mhfpacket.MHFPacket, ignoredSession *Session)
// WorldcastMHF broadcasts a packet to all sessions across all channel servers.
func (s *Server) WorldcastMHF(pkt mhfpacket.MHFPacket, ignoredSession *Session, ignoredChannel *Server) {
if s.Registry != nil {
s.Registry.Worldcast(pkt, ignoredSession, ignoredChannel)
return
}
for _, c := range s.Channels {
if c == ignoredChannel {
continue
@@ -317,12 +322,18 @@ func (s *Server) DiscordScreenShotSend(charName string, title string, descriptio
// FindSessionByCharID looks up a session by character ID across all channels.
func (s *Server) FindSessionByCharID(charID uint32) *Session {
if s.Registry != nil {
return s.Registry.FindSessionByCharID(charID)
}
for _, c := range s.Channels {
c.Lock()
for _, session := range c.sessions {
if session.charID == charID {
c.Unlock()
return session
}
}
c.Unlock()
}
return nil
}
@@ -341,7 +352,12 @@ func (s *Server) DisconnectUser(uid uint32) {
cids = append(cids, cid)
}
}
if s.Registry != nil {
s.Registry.DisconnectUser(cids)
return
}
for _, c := range s.Channels {
c.Lock()
for _, session := range c.sessions {
for _, cid := range cids {
if session.charID == cid {
@@ -350,6 +366,7 @@ func (s *Server) DisconnectUser(uid uint32) {
}
}
}
c.Unlock()
}
}