Files
Erupe/server/channelserver/client_connection_simulation_test.go
Houmgaor 07a587213d fix(channelserver): remove false race in PacketDuringLogout test
The test ran handleMsgMhfSavedata and logoutPlayer concurrently on the
same session, triggering data races on s.playtime and Save(). In
production the dispatch loop processes packets sequentially per session,
so this overlap is impossible. Run the operations sequentially to match
real behavior while still validating no data loss.
2026-03-01 18:56:52 +01:00

580 lines
16 KiB
Go

package channelserver
import (
"bytes"
"fmt"
"io"
"net"
"sync"
"testing"
"time"
"erupe-ce/network/mhfpacket"
"erupe-ce/server/channelserver/compression/nullcomp"
)
// ============================================================================
// CLIENT CONNECTION SIMULATION TESTS
// Tests that simulate actual client connections, not just mock sessions
//
// Purpose: Test the complete connection lifecycle as a real client would
// - TCP connection establishment
// - Packet exchange
// - Graceful disconnect
// - Ungraceful disconnect
// - Network errors
// ============================================================================
// MockNetConn simulates a net.Conn for testing
type MockNetConn struct {
readBuf *bytes.Buffer
writeBuf *bytes.Buffer
closed bool
mu sync.Mutex
readErr error
writeErr error
}
func NewMockNetConn() *MockNetConn {
return &MockNetConn{
readBuf: new(bytes.Buffer),
writeBuf: new(bytes.Buffer),
}
}
func (m *MockNetConn) Read(b []byte) (n int, err error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return 0, io.EOF
}
if m.readErr != nil {
return 0, m.readErr
}
return m.readBuf.Read(b)
}
func (m *MockNetConn) Write(b []byte) (n int, err error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return 0, io.ErrClosedPipe
}
if m.writeErr != nil {
return 0, m.writeErr
}
return m.writeBuf.Write(b)
}
func (m *MockNetConn) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
m.closed = true
return nil
}
func (m *MockNetConn) LocalAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 54001}
}
func (m *MockNetConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345}
}
func (m *MockNetConn) SetDeadline(t time.Time) error {
return nil
}
func (m *MockNetConn) SetReadDeadline(t time.Time) error {
return nil
}
func (m *MockNetConn) SetWriteDeadline(t time.Time) error {
return nil
}
func (m *MockNetConn) QueueRead(data []byte) {
m.mu.Lock()
defer m.mu.Unlock()
m.readBuf.Write(data)
}
func (m *MockNetConn) GetWritten() []byte {
m.mu.Lock()
defer m.mu.Unlock()
return m.writeBuf.Bytes()
}
func (m *MockNetConn) IsClosed() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.closed
}
// TestClientConnection_GracefulLoginLogout simulates a complete client session
// This is closer to what a real client does than handler-only tests
func TestClientConnection_GracefulLoginLogout(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "client_test_user")
charID := CreateTestCharacter(t, db, userID, "ClientChar")
t.Log("Simulating client connection with graceful logout")
// Simulate client connecting
mockConn := NewMockNetConn()
session := createTestSessionForServerWithChar(server, charID, "ClientChar")
// In real scenario, this would be set up by the connection handler
// For testing, we test handlers directly without starting packet loops
// Client sends save packet
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("ClientChar\x00"))
saveData[8000] = 0xAB
saveData[8001] = 0xCD
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("Failed to compress: %v", err)
}
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 12001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session, savePkt)
time.Sleep(100 * time.Millisecond)
// Client sends logout packet (graceful)
t.Log("Client sending logout packet")
logoutPkt := &mhfpacket.MsgSysLogout{}
handleMsgSysLogout(session, logoutPkt)
time.Sleep(100 * time.Millisecond)
// Verify connection closed
if !mockConn.IsClosed() {
// Note: Our mock doesn't auto-close, but real session would
t.Log("Mock connection not closed (expected for mock)")
}
// Verify data saved
var savedCompressed []byte
err = db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query savedata: %v", err)
}
if len(savedCompressed) == 0 {
t.Error("❌ No data saved after graceful logout")
} else {
decompressed, _ := nullcomp.Decompress(savedCompressed)
if len(decompressed) > 8001 {
if decompressed[8000] == 0xAB && decompressed[8001] == 0xCD {
t.Log("✓ Data saved correctly after graceful logout")
} else {
t.Error("❌ Data corrupted")
}
}
}
}
// TestClientConnection_UngracefulDisconnect simulates network failure
func TestClientConnection_UngracefulDisconnect(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "disconnect_user")
charID := CreateTestCharacter(t, db, userID, "DisconnectChar")
t.Log("Simulating ungraceful client disconnect (network error)")
session := createTestSessionForServerWithChar(server, charID, "DisconnectChar")
// Note: Not calling Start() - testing handlers directly
time.Sleep(50 * time.Millisecond)
// Client saves some data
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("DisconnectChar\x00"))
saveData[9000] = 0xEF
saveData[9001] = 0x12
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 13001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session, savePkt)
time.Sleep(100 * time.Millisecond)
// Simulate network failure - connection drops without logout packet
t.Log("Simulating network failure (no logout packet sent)")
// In real scenario, recvLoop would detect io.EOF and call logoutPlayer
logoutPlayer(session)
time.Sleep(100 * time.Millisecond)
// Verify data was saved despite ungraceful disconnect
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(savedCompressed) == 0 {
t.Error("❌ CRITICAL: No data saved after ungraceful disconnect")
t.Error("This means players lose data when they have connection issues!")
} else {
t.Log("✓ Data saved even after ungraceful disconnect")
}
}
// TestClientConnection_SessionTimeout simulates timeout disconnect
func TestClientConnection_SessionTimeout(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "timeout_user")
charID := CreateTestCharacter(t, db, userID, "TimeoutChar")
t.Log("Simulating session timeout (30s no packets)")
session := createTestSessionForServerWithChar(server, charID, "TimeoutChar")
// Note: Not calling Start() - testing handlers directly
time.Sleep(50 * time.Millisecond)
// Save data
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("TimeoutChar\x00"))
saveData[10000] = 0xFF
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 14001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session, savePkt)
time.Sleep(100 * time.Millisecond)
// Simulate timeout by setting lastPacket to long ago
session.lastPacket = time.Now().Add(-35 * time.Second)
// In production, invalidateSessions() goroutine would detect this
// and call logoutPlayer(session)
t.Log("Session timed out (>30s since last packet)")
logoutPlayer(session)
time.Sleep(100 * time.Millisecond)
// Verify data saved
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(savedCompressed) == 0 {
t.Error("❌ CRITICAL: No data saved after timeout disconnect")
} else {
decompressed, _ := nullcomp.Decompress(savedCompressed)
if len(decompressed) > 10000 && decompressed[10000] == 0xFF {
t.Log("✓ Data saved correctly after timeout")
} else {
t.Error("❌ Data corrupted or not saved")
}
}
}
// TestClientConnection_MultipleClientsSimultaneous simulates multiple clients
func TestClientConnection_MultipleClientsSimultaneous(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
numClients := 3
var wg sync.WaitGroup
wg.Add(numClients)
t.Logf("Simulating %d clients connecting simultaneously", numClients)
for clientNum := 0; clientNum < numClients; clientNum++ {
go func(num int) {
defer wg.Done()
username := fmt.Sprintf("multi_client_%d", num)
charName := fmt.Sprintf("MultiClient%d", num)
userID := CreateTestUser(t, db, username)
charID := CreateTestCharacter(t, db, userID, charName)
session := createTestSessionForServerWithChar(server, charID, charName)
// Note: Not calling Start() - testing handlers directly
time.Sleep(30 * time.Millisecond)
// Each client saves their own data
saveData := make([]byte, 150000)
copy(saveData[88:], []byte(charName+"\x00"))
saveData[11000+num] = byte(num)
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: uint32(15000 + num),
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session, savePkt)
time.Sleep(50 * time.Millisecond)
// Graceful logout
logoutPlayer(session)
time.Sleep(50 * time.Millisecond)
// Verify individual client's data
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Errorf("Client %d: Failed to query: %v", num, err)
return
}
if len(savedCompressed) > 0 {
decompressed, _ := nullcomp.Decompress(savedCompressed)
if len(decompressed) > 11000+num {
if decompressed[11000+num] == byte(num) {
t.Logf("Client %d: ✓ Data saved correctly", num)
} else {
t.Errorf("Client %d: ❌ Data corrupted", num)
}
}
} else {
t.Errorf("Client %d: ❌ No data saved", num)
}
}(clientNum)
}
wg.Wait()
t.Log("All clients disconnected")
}
// TestClientConnection_SaveDuringCombat simulates saving while in quest
// This tests if being in a stage affects save behavior
func TestClientConnection_SaveDuringCombat(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "combat_user")
charID := CreateTestCharacter(t, db, userID, "CombatChar")
t.Log("Simulating save/logout while in quest/stage")
session := createTestSessionForServerWithChar(server, charID, "CombatChar")
// Simulate being in a stage (quest)
// In real scenario, session.stage would be set when entering quest
// For now, we'll just test the basic save/logout flow
// Note: Not calling Start() - testing handlers directly
time.Sleep(50 * time.Millisecond)
// Save data during "combat"
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("CombatChar\x00"))
saveData[12000] = 0xAA
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 16001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session, savePkt)
time.Sleep(100 * time.Millisecond)
// Disconnect while in stage
t.Log("Player disconnects during quest")
logoutPlayer(session)
time.Sleep(100 * time.Millisecond)
// Verify data saved even during combat
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(savedCompressed) > 0 {
decompressed, _ := nullcomp.Decompress(savedCompressed)
if len(decompressed) > 12000 && decompressed[12000] == 0xAA {
t.Log("✓ Data saved correctly even during quest")
} else {
t.Error("❌ Data not saved correctly during quest")
}
} else {
t.Error("❌ CRITICAL: No data saved when disconnecting during quest")
}
}
// TestClientConnection_ReconnectAfterCrash simulates client crash and reconnect
func TestClientConnection_ReconnectAfterCrash(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "crash_user")
charID := CreateTestCharacter(t, db, userID, "CrashChar")
t.Log("Simulating client crash and immediate reconnect")
// First session - client crashes
session1 := createTestSessionForServerWithChar(server, charID, "CrashChar")
// Not calling Start()
time.Sleep(50 * time.Millisecond)
// Save some data before crash
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("CrashChar\x00"))
saveData[13000] = 0xBB
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 17001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session1, savePkt)
time.Sleep(50 * time.Millisecond)
// Client crashes (ungraceful disconnect)
t.Log("Client crashes (no logout packet)")
logoutPlayer(session1)
time.Sleep(100 * time.Millisecond)
// Client reconnects immediately
t.Log("Client reconnects after crash")
session2 := createTestSessionForServerWithChar(server, charID, "CrashChar")
// Not calling Start()
time.Sleep(50 * time.Millisecond)
// Load data
loadPkt := &mhfpacket.MsgMhfLoaddata{
AckHandle: 18001,
}
handleMsgMhfLoaddata(session2, loadPkt)
time.Sleep(50 * time.Millisecond)
// Verify data from before crash
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(savedCompressed) > 0 {
decompressed, _ := nullcomp.Decompress(savedCompressed)
if len(decompressed) > 13000 && decompressed[13000] == 0xBB {
t.Log("✓ Data recovered correctly after crash")
} else {
t.Error("❌ Data lost or corrupted after crash")
}
} else {
t.Error("❌ CRITICAL: All data lost after crash")
}
logoutPlayer(session2)
}
// TestClientConnection_PacketDuringLogout tests that a save followed by
// logout produces valid, non-corrupted data. In production the dispatch
// loop processes packets sequentially per session, so these two operations
// can never truly overlap — we test them in the same order here.
func TestClientConnection_PacketDuringLogout(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "race_user")
charID := CreateTestCharacter(t, db, userID, "RaceChar")
t.Log("Testing save-then-logout sequence")
session := createTestSessionForServerWithChar(server, charID, "RaceChar")
// Note: Not calling Start() - testing handlers directly
time.Sleep(50 * time.Millisecond)
// Prepare save packet
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("RaceChar\x00"))
saveData[14000] = 0xCC
compressed, _ := nullcomp.Compress(saveData)
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 19001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
// Process save then logout sequentially, matching production dispatch order
handleMsgMhfSavedata(session, savePkt)
t.Log("Save packet processed")
logoutPlayer(session)
t.Log("Logout processed")
// Verify final state
var savedCompressed []byte
err := db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(savedCompressed) == 0 {
t.Fatal("No savedata in DB after save+logout sequence")
}
decompressed, err := nullcomp.Decompress(savedCompressed)
if err != nil {
t.Fatalf("Saved data is not valid compressed data: %v", err)
}
if len(decompressed) < 15000 {
t.Fatalf("Decompressed data too short (%d bytes), expected at least 15000", len(decompressed))
}
t.Log("Save-then-logout sequence completed with valid data")
}