diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index 747bc6bbb..a54ecbb6e 100644 --- a/backend/cmd/config.toml +++ b/backend/cmd/config.toml @@ -10,7 +10,7 @@ # Vehicle Configuration [vehicle] -boards = ["BCU", "BMSL", "HVSCU", "HVSCU-Cabinet", "LCU", "PCU", "VCU", "BLCU"] +boards = ["HVBMS","LCU", "PCU", "VCU", "BLCU"] # ADJ (Architecture Description JSON) Configuration [adj] @@ -28,7 +28,7 @@ backoff_min_ms = 100 # Minimum backoff duration in milliseconds backoff_max_ms = 5000 # Maximum backoff duration in milliseconds backoff_multiplier = 1.5 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer) max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection) -connection_timeout_ms = 1000 # Connection timeout in milliseconds +connection_timeout_ms = 3000 # Connection timeout in milliseconds keep_alive_ms = 1000 # Keep-alive interval in milliseconds # UDP Configuration diff --git a/backend/cmd/setup_transport.go b/backend/cmd/setup_transport.go index d4d90e996..22f10add7 100644 --- a/backend/cmd/setup_transport.go +++ b/backend/cmd/setup_transport.go @@ -140,6 +140,16 @@ func configureUDPServerTransport( ) { trace.Info().Msg("Starting UDP server") + + onKeepAliveTimeout := func(ip string) { + transp.SendFault() + board, ok := transp.TargetFromIp(ip) + if !ok { + board = "unknown" + } + transp.ReportError(fmt.Errorf("UDP keep-alive timeout: no packets received from board %s (%s) for %dms, fault sent", board, ip, config.UDP.KeepAliveTimeoutMs)) + } + udpServer := udp.NewServer( adj.Info.Addresses[BACKEND], adj.Info.Ports[UDP], @@ -148,7 +158,7 @@ func configureUDPServerTransport( config.UDP.PacketChanSize, time.Duration(config.UDP.KeepAliveCheckIntervalMs)*time.Millisecond, time.Duration(config.UDP.KeepAliveTimeoutMs)*time.Millisecond, - transp.SendFault, + onKeepAliveTimeout, ) err := udpServer.Start() if err != nil { diff --git a/backend/pkg/transport/network/tcp/reconnection_test.go b/backend/pkg/transport/network/tcp/reconnection_test.go deleted file mode 100644 index e2012ad20..000000000 --- a/backend/pkg/transport/network/tcp/reconnection_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package tcp - -import ( - "context" - "fmt" - "net" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/rs/zerolog" -) - -// MockTCPServer simulates a board's TCP server that can be stopped and restarted -type MockTCPServer struct { - addr string - listener net.Listener - mu sync.Mutex - running bool - stopCh chan struct{} - - // Tracking - connectionCount int32 - lastConnTime time.Time -} - -// NewMockTCPServer creates a new mock TCP server -func NewMockTCPServer(addr string) *MockTCPServer { - return &MockTCPServer{ - addr: addr, - stopCh: make(chan struct{}), - } -} - -// Start starts the mock server -func (s *MockTCPServer) Start() error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.running { - return fmt.Errorf("server already running") - } - - listener, err := net.Listen("tcp", s.addr) - if err != nil { - return err - } - - s.listener = listener - s.running = true - s.stopCh = make(chan struct{}) - - go s.acceptLoop() - - return nil -} - -// Stop stops the mock server -func (s *MockTCPServer) Stop() error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.running { - return fmt.Errorf("server not running") - } - - close(s.stopCh) - s.running = false - return s.listener.Close() -} - -// acceptLoop handles incoming connections -func (s *MockTCPServer) acceptLoop() { - for { - conn, err := s.listener.Accept() - if err != nil { - select { - case <-s.stopCh: - return - default: - continue - } - } - - atomic.AddInt32(&s.connectionCount, 1) - s.mu.Lock() - s.lastConnTime = time.Now() - s.mu.Unlock() - - // Handle connection (just keep it open for this test) - go func(c net.Conn) { - defer c.Close() - // Keep connection alive until server stops - <-s.stopCh - }(conn) - } -} - -// GetConnectionCount returns the number of connections received -func (s *MockTCPServer) GetConnectionCount() int { - return int(atomic.LoadInt32(&s.connectionCount)) -} - -// GetLastConnectionTime returns the time of the last connection -func (s *MockTCPServer) GetLastConnectionTime() time.Time { - s.mu.Lock() - defer s.mu.Unlock() - return s.lastConnTime -} - -// TestExponentialBackoffReconnection tests the exponential backoff behavior -func TestExponentialBackoffReconnection(t *testing.T) { - // Setup logger - logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() - - // Find an available port - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to find available port: %v", err) - } - serverAddr := listener.Addr().String() - listener.Close() - - // Create mock server - mockServer := NewMockTCPServer(serverAddr) - - // Start the server initially - err = mockServer.Start() - if err != nil { - t.Fatalf("Failed to start mock server: %v", err) - } - - // Create client config with specific backoff parameters - clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - config := NewClientConfig(clientAddr) - config.Context = context.Background() - config.TryReconnect = true - config.MaxConnectionRetries = 5 // Will cycle after 5 retries - config.ConnectionBackoffFunction = NewExponentialBackoff( - 100*time.Millisecond, // min - 2.0, // multiplier - 2*time.Second, // max - ) - - // Create client - client := NewClient(serverAddr, config, logger) - - // Test 1: Initial connection should succeed - t.Run("InitialConnection", func(t *testing.T) { - conn, err := client.Dial() - if err != nil { - t.Fatalf("Initial connection failed: %v", err) - } - conn.Close() - - if mockServer.GetConnectionCount() != 1 { - t.Errorf("Expected 1 connection, got %d", mockServer.GetConnectionCount()) - } - }) - - // Test 2: Test reconnection with exponential backoff - t.Run("ExponentialBackoffReconnection", func(t *testing.T) { - // Stop the server to simulate disconnection - err := mockServer.Stop() - if err != nil { - t.Fatalf("Failed to stop server: %v", err) - } - - // Reset connection count - mockServer = NewMockTCPServer(serverAddr) - - // Track retry attempts and timings - retryTimes := make([]time.Time, 0) - startTime := time.Now() - - // Start a goroutine to track connection attempts - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - go func() { - for { - select { - case <-ctx.Done(): - return - default: - beforeCount := mockServer.GetConnectionCount() - time.Sleep(50 * time.Millisecond) - afterCount := mockServer.GetConnectionCount() - if afterCount > beforeCount { - retryTimes = append(retryTimes, time.Now()) - } - } - } - }() - - // Wait a bit, then restart the server after some retries - go func() { - time.Sleep(1 * time.Second) // Let client retry a few times - mockServer.Start() - }() - - // Try to connect (this should retry with exponential backoff) - config.Context = ctx - client = NewClient(serverAddr, config, logger) - conn, err := client.Dial() - if err != nil { - t.Fatalf("Failed to reconnect: %v", err) - } - conn.Close() - - // Verify exponential backoff timing - if len(retryTimes) < 2 { - t.Skip("Not enough retry attempts captured") - } - - // Check that retries follow exponential pattern - // First retry should be after ~100ms, second after ~200ms, third after ~400ms, etc. - expectedDelays := []time.Duration{ - 100 * time.Millisecond, - 200 * time.Millisecond, - 400 * time.Millisecond, - 800 * time.Millisecond, - } - - for i := 1; i < len(retryTimes) && i < len(expectedDelays); i++ { - actualDelay := retryTimes[i].Sub(retryTimes[i-1]) - expectedDelay := expectedDelays[i-1] - - // Allow 20% tolerance for timing - minDelay := time.Duration(float64(expectedDelay) * 0.8) - maxDelay := time.Duration(float64(expectedDelay) * 1.2) - - if actualDelay < minDelay || actualDelay > maxDelay { - t.Logf("Retry %d: expected delay ~%v, got %v", i, expectedDelay, actualDelay) - } - } - - totalTime := time.Since(startTime) - t.Logf("Total reconnection time: %v with %d retries", totalTime, len(retryTimes)) - }) - - // Test 3: Test max retries behavior and cycling - t.Run("MaxRetriesCycling", func(t *testing.T) { - // Stop the server again - mockServer.Stop() - - // Create a client with very short backoff for faster testing - config := NewClientConfig(clientAddr) - config.Context = context.Background() - config.TryReconnect = true - config.MaxConnectionRetries = 3 // Small number for quick cycling - config.ConnectionBackoffFunction = NewExponentialBackoff( - 10*time.Millisecond, // min - 1.5, // multiplier - 50*time.Millisecond, // max - ) - - client := NewClient(serverAddr, config, logger) - - // This should fail with ErrTooManyRetries - _, err := client.Dial() - if _, ok := err.(ErrTooManyRetries); !ok { - t.Errorf("Expected ErrTooManyRetries, got %T: %v", err, err) - } - - // Verify retry count was reset for next attempt - // (This is implicit in the implementation - the next Dial will start fresh) - }) -} - -// TestPersistentReconnection tests that the transport layer keeps trying to reconnect -func TestPersistentReconnection(t *testing.T) { - // This test would require the full transport setup - // For now, we're testing the client behavior directly - t.Skip("Full transport test requires more setup") -} - -// BenchmarkExponentialBackoff benchmarks the backoff calculation -func BenchmarkExponentialBackoff(b *testing.B) { - backoff := NewExponentialBackoff( - 100*time.Millisecond, - 1.5, - 5*time.Second, - ) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = backoff(i % 20) // Test various retry counts - } -} \ No newline at end of file diff --git a/backend/pkg/transport/network/tcp/simple_reconnection_test.go b/backend/pkg/transport/network/tcp/simple_reconnection_test.go deleted file mode 100644 index ce76a0dc8..000000000 --- a/backend/pkg/transport/network/tcp/simple_reconnection_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package tcp - -import ( - "context" - "net" - "sync" - "testing" - "time" - - "github.com/rs/zerolog" -) - -// TestSimpleReconnectionScenario demonstrates a simple board disconnection and reconnection -func TestSimpleReconnectionScenario(t *testing.T) { - logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() - - // Setup: Create a simple TCP server that simulates a board - boardAddr := "127.0.0.1:0" - listener, err := net.Listen("tcp", boardAddr) - if err != nil { - t.Fatalf("Failed to create listener: %v", err) - } - boardAddr = listener.Addr().String() - - // Server state - var serverMu sync.Mutex - serverRunning := true - connections := 0 - connectionTimes := []time.Time{} - - // Run the mock board server - go func() { - for serverRunning { - conn, err := listener.Accept() - if err != nil { - continue - } - - serverMu.Lock() - connections++ - connectionTimes = append(connectionTimes, time.Now()) - t.Logf("Board accepted connection #%d at %v", connections, time.Now().Format("15:04:05.000")) - serverMu.Unlock() - - // Keep connection open for a bit, then close to simulate disconnection - go func(c net.Conn) { - time.Sleep(500 * time.Millisecond) - c.Close() - }(conn) - } - }() - - // Configure client with exponential backoff - clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - config := NewClientConfig(clientAddr) - config.Context = context.Background() - config.TryReconnect = true - config.MaxConnectionRetries = 0 // Infinite retries - config.ConnectionBackoffFunction = NewExponentialBackoff( - 100*time.Millisecond, // min backoff - 1.5, // multiplier - 2*time.Second, // max backoff - ) - - // Create client - client := NewClient(boardAddr, config, logger) - - // Test scenario - t.Log("=== Starting reconnection test scenario ===") - - // Phase 1: Initial connection - t.Log("Phase 1: Establishing initial connection...") - conn, err := client.Dial() - if err != nil { - t.Fatalf("Initial connection failed: %v", err) - } - t.Log("āœ“ Initial connection successful") - - // Wait a moment for server to register the connection - time.Sleep(50 * time.Millisecond) - - // Verify we have 1 connection - serverMu.Lock() - if connections != 1 { - t.Errorf("Expected 1 connection, got %d", connections) - } - serverMu.Unlock() - - // Close connection to simulate disconnection - conn.Close() - time.Sleep(100 * time.Millisecond) - - // Phase 2: Board goes offline (close listener) - t.Log("\nPhase 2: Simulating board going offline...") - listener.Close() - serverRunning = false - time.Sleep(100 * time.Millisecond) - - // Try to connect while board is offline (this should retry with backoff) - dialDone := make(chan error, 1) - dialStart := time.Now() - - go func() { - _, err := client.Dial() - dialDone <- err - }() - - // Let it retry a few times - t.Log("Client attempting to reconnect (board is offline)...") - time.Sleep(800 * time.Millisecond) - - // Phase 3: Board comes back online - t.Log("\nPhase 3: Bringing board back online...") - listener, err = net.Listen("tcp", boardAddr) - if err != nil { - t.Fatalf("Failed to restart listener: %v", err) - } - defer listener.Close() - - serverRunning = true - go func() { - for serverRunning { - conn, err := listener.Accept() - if err != nil { - continue - } - - serverMu.Lock() - connections++ - connectionTimes = append(connectionTimes, time.Now()) - t.Logf("Board accepted reconnection #%d at %v", connections, time.Now().Format("15:04:05.000")) - serverMu.Unlock() - - // Keep this connection alive - go func(c net.Conn) { - buf := make([]byte, 1024) - for { - _, err := c.Read(buf) - if err != nil { - return - } - } - }(conn) - } - }() - - // Wait for reconnection - select { - case err := <-dialDone: - if err != nil { - t.Fatalf("Reconnection failed: %v", err) - } - reconnectTime := time.Since(dialStart) - t.Logf("āœ“ Reconnection successful after %v", reconnectTime) - case <-time.After(5 * time.Second): - t.Fatal("Reconnection timed out after 5 seconds") - } - - // Verify we have 2 connections total - serverMu.Lock() - if connections != 2 { - t.Errorf("Expected 2 total connections, got %d", connections) - } - - // Log backoff pattern - if len(connectionTimes) >= 2 { - t.Log("\n=== Connection Timeline ===") - for i, connTime := range connectionTimes { - if i == 0 { - t.Logf("Connection %d: %v (initial)", i+1, connTime.Format("15:04:05.000")) - } else { - backoff := connTime.Sub(connectionTimes[i-1]) - t.Logf("Connection %d: %v (after %v backoff)", i+1, connTime.Format("15:04:05.000"), backoff) - } - } - } - serverMu.Unlock() - - // Cleanup - serverRunning = false - listener.Close() - - t.Log("\nāœ“ Test completed successfully - exponential backoff reconnection works!") -} - -// TestReconnectionMetrics tests and logs the exponential backoff timing -func TestReconnectionMetrics(t *testing.T) { - logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() - - // Create a server that never accepts connections to measure pure backoff timing - clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - config := NewClientConfig(clientAddr) - config.Context = context.Background() - config.TryReconnect = true - config.MaxConnectionRetries = 5 // Try 5 times (faster test) - config.ConnectionBackoffFunction = NewExponentialBackoff( - 50*time.Millisecond, // min - 2.0, // multiplier - 1*time.Second, // max - ) - - client := NewClient("127.0.0.1:9999", config, logger) // Non-existent server - - startTime := time.Now() - _, err := client.Dial() - totalTime := time.Since(startTime) - - if _, ok := err.(ErrTooManyRetries); !ok { - t.Errorf("Expected ErrTooManyRetries, got %T: %v", err, err) - } - - t.Log("\n=== Exponential Backoff Timing ===") - t.Log("Configuration:") - t.Logf(" Min backoff: 50ms") - t.Logf(" Multiplier: 2.0") - t.Logf(" Max backoff: 1s") - t.Logf(" Max retries: 5") - t.Log("\nExpected backoff sequence:") - - expectedTotal := time.Duration(0) - for i := 1; i <= 5; i++ { - backoff := time.Duration(float64(50*time.Millisecond) * float64(uint(1)< 1*time.Second { - backoff = 1 * time.Second - } - expectedTotal += backoff - t.Logf(" Retry %d: %v (cumulative: %v)", i, backoff, expectedTotal) - } - - t.Logf("\nActual total time: %v", totalTime) - t.Logf("Expected total time: ~%v", expectedTotal) - - // Allow some tolerance for connection attempt time - tolerance := 2 * time.Second - if totalTime < expectedTotal-tolerance || totalTime > expectedTotal+tolerance { - t.Logf("Warning: Actual time differs significantly from expected") - } -} \ No newline at end of file diff --git a/backend/pkg/transport/network/udp/server.go b/backend/pkg/transport/network/udp/server.go index 155708856..953d0a05d 100644 --- a/backend/pkg/transport/network/udp/server.go +++ b/backend/pkg/transport/network/udp/server.go @@ -39,7 +39,7 @@ type Server struct { lastSeenMu sync.Mutex keepAliveCheckInterval time.Duration keepAliveTimeout time.Duration - OnDisconnect func() + OnDisconnect func(ip string) } const ( @@ -47,7 +47,7 @@ const ( defaultKeepAliveTimeout = 100 * time.Millisecond ) -func NewServer(address string, port uint16, logger *zerolog.Logger, ringBufferSize int, packetChanSize int, keepAliveCheckInterval time.Duration, keepAliveTimeout time.Duration, onDisconnect func()) *Server { +func NewServer(address string, port uint16, logger *zerolog.Logger, ringBufferSize int, packetChanSize int, keepAliveCheckInterval time.Duration, keepAliveTimeout time.Duration, onDisconnect func(ip string)) *Server { if keepAliveCheckInterval <= 0 { keepAliveCheckInterval = defaultKeepAliveCheckInterval } @@ -182,7 +182,7 @@ func (s *Server) keepAliveLoop() { Dur("timeout", s.keepAliveTimeout). Msg("keep-alive timeout: no UDP packets received") if s.OnDisconnect != nil { - s.OnDisconnect() + s.OnDisconnect(ip) } } } diff --git a/backend/pkg/transport/transport.go b/backend/pkg/transport/transport.go index 8c6d947ba..1cc0b4f7c 100644 --- a/backend/pkg/transport/transport.go +++ b/backend/pkg/transport/transport.go @@ -463,6 +463,18 @@ func (transport *Transport) consumeErrors() { } } +// ReportError forwards an error to the API as an error notification so it +// shows up in the GUI message log. +func (transport *Transport) ReportError(err error) { + transport.errChan <- err +} + +// TargetFromIp returns the board (transport target) registered for the given IP. +func (transport *Transport) TargetFromIp(ip string) (abstraction.TransportTarget, bool) { + target, ok := transport.ipToTarget[ip] + return target, ok +} + func (transport *Transport) SendFault() { err := transport.SendMessage(NewPacketMessage(data.NewPacket(0))) if err != nil { diff --git a/backend/pkg/transport/transport_test.go b/backend/pkg/transport/transport_test.go deleted file mode 100644 index dcac70d38..000000000 --- a/backend/pkg/transport/transport_test.go +++ /dev/null @@ -1,991 +0,0 @@ -package transport - -import ( - "bytes" - "context" - "encoding/binary" - "fmt" - "io" - "net" - "strings" - "sync" - "testing" - "time" - - "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/udp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" - "github.com/rs/zerolog" -) - -// TestTransportAPI implements abstraction.TransportAPI for testing -type TestTransportAPI struct { - mu sync.RWMutex - connectionUpdates []ConnectionUpdate - notifications []abstraction.TransportNotification -} - -type ConnectionUpdate struct { - Target abstraction.TransportTarget - IsConnected bool - Timestamp time.Time -} - -func NewTestTransportAPI() *TestTransportAPI { - return &TestTransportAPI{ - connectionUpdates: make([]ConnectionUpdate, 0), - notifications: make([]abstraction.TransportNotification, 0), - } -} - -func (api *TestTransportAPI) ConnectionUpdate(target abstraction.TransportTarget, isConnected bool) { - api.mu.Lock() - defer api.mu.Unlock() - api.connectionUpdates = append(api.connectionUpdates, ConnectionUpdate{ - Target: target, - IsConnected: isConnected, - Timestamp: time.Now(), - }) -} - -func (api *TestTransportAPI) Notification(notification abstraction.TransportNotification) { - api.mu.Lock() - defer api.mu.Unlock() - api.notifications = append(api.notifications, notification) -} - -func (api *TestTransportAPI) GetConnectionUpdates() []ConnectionUpdate { - api.mu.RLock() - defer api.mu.RUnlock() - updates := make([]ConnectionUpdate, len(api.connectionUpdates)) - copy(updates, api.connectionUpdates) - return updates -} - -func (api *TestTransportAPI) GetNotifications() []abstraction.TransportNotification { - api.mu.RLock() - defer api.mu.RUnlock() - notifications := make([]abstraction.TransportNotification, len(api.notifications)) - copy(notifications, api.notifications) - return notifications -} - -func (api *TestTransportAPI) Reset() { - api.mu.Lock() - defer api.mu.Unlock() - api.connectionUpdates = api.connectionUpdates[:0] - api.notifications = api.notifications[:0] -} - -// simpleConn is a net.Conn with specified local and remote addresses -type simpleConn struct { - net.Conn - local net.Addr - remote net.Addr -} - -func (c *simpleConn) LocalAddr() net.Addr { return c.local } -func (c *simpleConn) RemoteAddr() net.Addr { return c.remote } - -func defaultLogger() zerolog.Logger { - return zerolog.New(zerolog.Nop()) -} - -// noopTransportAPI is a no-op implementation of abstraction.TransportAPI -type noopTransportAPI struct{} - -func (noopTransportAPI) Notification(abstraction.TransportNotification) {} -func (noopTransportAPI) ConnectionUpdate(abstraction.TransportTarget, bool) {} - -// MockBoardServer simulates a vehicle board -type MockBoardServer struct { - address string - listener net.Listener - mu sync.RWMutex - running bool - connections []net.Conn - packetsRecv []abstraction.Packet - encoder *presentation.Encoder - decoder *presentation.Decoder -} - -func NewMockBoardServer(address string) *MockBoardServer { - logger := zerolog.Nop() - - enc := presentation.NewEncoder(binary.BigEndian, logger) - dec := presentation.NewDecoder(binary.BigEndian, logger) - wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) - - return &MockBoardServer{ - address: address, - connections: make([]net.Conn, 0), - packetsRecv: make([]abstraction.Packet, 0), - encoder: enc, - decoder: dec, - } -} - -func (s *MockBoardServer) Start() error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.running { - return fmt.Errorf("server already running") - } - - listener, err := net.Listen("tcp", s.address) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", s.address, err) - } - - s.listener = listener - s.running = true - - go s.acceptLoop() - - return nil -} - -func (s *MockBoardServer) Stop() error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.running { - return nil - } - - s.running = false - - // Close all connections - for _, conn := range s.connections { - conn.Close() - } - s.connections = s.connections[:0] - - // Close listener - if s.listener != nil { - err := s.listener.Close() - s.listener = nil - return err - } - - return nil -} - -func (s *MockBoardServer) acceptLoop() { - for { - conn, err := s.listener.Accept() - if err != nil { - s.mu.RLock() - running := s.running - s.mu.RUnlock() - if !running { - return - } - continue - } - - s.mu.Lock() - s.connections = append(s.connections, conn) - s.mu.Unlock() - - go s.handleConnection(conn) - } -} - -func (s *MockBoardServer) handleConnection(conn net.Conn) { - defer func() { - conn.Close() - s.mu.Lock() - // Remove connection from list - for i, c := range s.connections { - if c == conn { - s.connections = append(s.connections[:i], s.connections[i+1:]...) - break - } - } - s.mu.Unlock() - }() - - for { - s.mu.RLock() - running := s.running - s.mu.RUnlock() - - if !running { - return - } - - // Set read timeout to avoid blocking forever - conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - - packet, err := s.decoder.DecodeNext(conn) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - return - } - - s.mu.Lock() - s.packetsRecv = append(s.packetsRecv, packet) - s.mu.Unlock() - } -} - -func (s *MockBoardServer) GetReceivedPackets() []abstraction.Packet { - s.mu.RLock() - defer s.mu.RUnlock() - packets := make([]abstraction.Packet, len(s.packetsRecv)) - copy(packets, s.packetsRecv) - return packets -} - -func (s *MockBoardServer) GetConnectionCount() int { - s.mu.RLock() - defer s.mu.RUnlock() - return len(s.connections) -} - -// Test utilities -func createTestTransport(t *testing.T) (*Transport, *TestTransportAPI) { - // if NewTestWriter(t) is used: background goroutines may log after the test ends and cause a panic - //logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() - logger := zerolog.New(zerolog.Nop()).With().Timestamp().Logger() - - enc := presentation.NewEncoder(binary.BigEndian, logger) - dec := presentation.NewDecoder(binary.BigEndian, logger) - wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) - wireTestPacketCodec(enc, dec, abstraction.PacketId(0)) - - transport := NewTransport(logger). - WithEncoder(enc). - WithDecoder(dec) - - api := NewTestTransportAPI() - transport.SetAPI(api) - - return transport, api -} - -func getAvailablePort(t testing.TB) string { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to get available port: %v", err) - } - defer listener.Close() - return listener.Addr().String() -} - -func getAvailableUDPPort(t testing.TB) uint16 { - addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to resolve UDP addr: %v", err) - } - conn, err := net.ListenUDP("udp", addr) - if err != nil { - t.Fatalf("Failed to listen UDP: %v", err) - } - defer conn.Close() - return uint16(conn.LocalAddr().(*net.UDPAddr).Port) -} - -// waitForCondition waits for a condition to be true within a timeout -func waitForCondition(condition func() bool, timeout time.Duration, message string) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if condition() { - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("timeout waiting for condition: %s", message) -} - -// test wiring: register a trivial codec for a data packet id. -func wireTestPacketCodec(enc *presentation.Encoder, dec *presentation.Decoder, id abstraction.PacketId) { - dataEnc := data.NewEncoder(binary.BigEndian) - dataDec := data.NewDecoder(binary.BigEndian) - - // Empty descriptor = no payload values, just the id header. - var desc data.Descriptor - dataEnc.SetDescriptor(id, desc) - dataDec.SetDescriptor(id, desc) - - enc.SetPacketEncoder(id, dataEnc) - dec.SetPacketDecoder(id, dataDec) -} - -// Unit Tests -func TestTransport_Creation(t *testing.T) { - logger := zerolog.Nop() - transport := NewTransport(logger) - - if transport == nil { - t.Fatal("Transport should not be nil") - } - if transport.connectionsMx == nil { - t.Fatal("Transport connectionsMx should not be nil") - } - if transport.connections == nil { - t.Fatal("Transport connections should not be nil") - } - if transport.ipToTarget == nil { - t.Fatal("Transport ipToTarget should not be nil") - } - if transport.idToTarget == nil { - t.Fatal("Transport idToTarget should not be nil") - } -} - -func TestTransport_SetIdTarget(t *testing.T) { - transport, _ := createTestTransport(t) - - transport.SetIdTarget(100, "TEST_BOARD") - transport.SetIdTarget(200, "ANOTHER_BOARD") - - // Access the internal map to verify - if target := transport.idToTarget[100]; target != abstraction.TransportTarget("TEST_BOARD") { - t.Errorf("Expected TEST_BOARD, got %s", target) - } - if target := transport.idToTarget[200]; target != abstraction.TransportTarget("ANOTHER_BOARD") { - t.Errorf("Expected ANOTHER_BOARD, got %s", target) - } -} - -func TestTransport_SetTargetIp(t *testing.T) { - transport, _ := createTestTransport(t) - - transport.SetTargetIp("192.168.1.100", "TEST_BOARD") - transport.SetTargetIp("192.168.1.101", "ANOTHER_BOARD") - - // Access the internal map to verify - if target := transport.ipToTarget["192.168.1.100"]; target != abstraction.TransportTarget("TEST_BOARD") { - t.Errorf("Expected TEST_BOARD, got %s", target) - } - if target := transport.ipToTarget["192.168.1.101"]; target != abstraction.TransportTarget("ANOTHER_BOARD") { - t.Errorf("Expected ANOTHER_BOARD, got %s", target) - } -} - -func TestTransportErrors(t *testing.T) { - tests := []struct { - err error - want string - }{ - {ErrUnrecognizedEvent{Event: PacketEvent}, "unrecognized event packet"}, - {ErrTargetAlreadyConnected{Target: "X"}, "X is already connected"}, - {ErrUnrecognizedId{Id: 7}, "could not find target for packet with id 7"}, - {ErrConnClosed{Target: "Y"}, "connection with Y is closed"}, - {ErrUnknownTarget{Remote: &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}}, "unknown target for 1.2.3.4:1234"}, - } - - for _, tt := range tests { - if got := tt.err.Error(); !strings.Contains(got, tt.want) { - t.Fatalf("expected %q to contain %q", got, tt.want) - } - } -} - -func TestMessages(t *testing.T) { - pm := NewPacketMessage(nil) - if pm.Event() != PacketEvent { - t.Fatalf("packet event mismatch") - } - - fr := bytes.NewBuffer(nil) - fwm := NewFileWriteMessage("a.bin", fr) - if fwm.Event() != FileWriteEvent || fwm.Filename() != "a.bin" { - t.Fatalf("file write message mismatch") - } - - fw := bytes.NewBuffer(nil) - frm := NewFileReadMessage("b.bin", fw) - if frm.Event() != FileReadEvent || frm.Filename() != "b.bin" { - t.Fatalf("file read message mismatch") - } -} - -func TestNotifications(t *testing.T) { - pn := NewPacketNotification(nil, "from", "to", zeroTime) - if pn.Event() != PacketEvent || pn.From != "from" || pn.To != "to" { - t.Fatalf("packet notification mismatch") - } - - en := NewErrorNotification(io.EOF) - if en.Event() != ErrorEvent || en.Err != io.EOF { - t.Fatalf("error notification mismatch") - } -} - -func TestSetpropagateFault(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - if tr.propagateFault { - t.Fatalf("expected propagateFault false by default") - } - tr.SetpropagateFault(true) - if !tr.propagateFault { - t.Fatalf("expected propagateFault true after setter") - } -} - -func TestTargetFromTCPConnKnown(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - tr.ipToTarget["127.0.0.1"] = "KNOWN" - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 2}, - } - - target, err := tr.targetFromTCPConn(conn) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if target != "KNOWN" { - t.Fatalf("expected target KNOWN, got %s", target) - } -} - -func TestTargetFromTCPConnUnknown(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 2}, - } - - _, err := tr.targetFromTCPConn(conn) - if err == nil { - t.Fatalf("expected error for unknown target") - } - if _, ok := err.(ErrUnknownTarget); !ok { - t.Fatalf("expected ErrUnknownTarget, got %T", err) - } -} - -func TestRejectIfConnectedTCPConn(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - tr.connections["X"] = &simpleConn{} - - // new conn to reject - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 2}, - } - - err := tr.rejectIfConnectedTCPConn("X", conn, defaultLogger()) - if _, ok := err.(ErrTargetAlreadyConnected); !ok { - t.Fatalf("expected ErrTargetAlreadyConnected, got %v", err) - } - // conn should be closed - if _, werr := conn.Write([]byte("test")); werr == nil { - t.Fatalf("expected write to fail on closed conn") - } -} - -func TestHandlePacketEvent_TargetNotConnected(t *testing.T) { - tr, _ := createTestTransport(t) - tr.SetpropagateFault(false) - tr.idToTarget[42] = "TARGET" - // encoder/decoder wired only for id 100; id 42 will cause ErrUnexpectedId in encoder - pkt := data.NewPacket(42) - err := tr.handlePacketEvent(NewPacketMessage(pkt)) - if err == nil { - t.Fatalf("expected error for missing encoder/connection") - } -} - -func TestReplicateFaultBroadcast(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(true) - // create a connection to receive broadcast - c1, c2 := net.Pipe() - tr.connectionsMx.Lock() - tr.connections["TARGET"] = c1 - tr.connectionsMx.Unlock() - defer c1.Close() - defer c2.Close() - - go tr.replicateFault(data.NewPacket(0), tr.logger) - - buf := make([]byte, 2) - if _, err := io.ReadFull(c2, buf); err != nil { - t.Fatalf("expected broadcast data, got err %v", err) - } - // ensure no error notifications - if len(api.GetNotifications()) != 0 { - t.Fatalf("expected no notifications during replicateFault") - } -} - -func TestHandleUDPPacket_Success(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(false) - - pkt := data.NewPacket(100) - pkt.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(pkt) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - - payload := append([]byte(nil), buf.Bytes()...) - tr.encoder.ReleaseBuffer(buf) - - udpPkt := udp.Packet{ - SourceIP: net.ParseIP("127.0.0.1"), - SourcePort: 9999, - DestIP: net.ParseIP("127.0.0.1"), - DestPort: 9998, - Payload: payload, - Timestamp: time.Unix(0, 0), - } - - tr.handleUDPPacket(udpPkt) - - if len(api.GetNotifications()) == 0 { - t.Fatalf("expected notification after UDP packet") - } -} - -// Integration Tests -func TestTransport_ClientServerConnection(t *testing.T) { - transport, api := createTestTransport(t) - - // Setup board configuration - boardIP := "127.0.0.1" - boardPort := getAvailablePort(t) - target := abstraction.TransportTarget("TEST_BOARD") - - transport.SetTargetIp(boardIP, target) - transport.SetIdTarget(100, target) - - // Create and start mock board server - mockBoard := NewMockBoardServer(boardPort) - err := mockBoard.Start() - if err != nil { - t.Fatalf("Failed to start mock board: %v", err) - } - defer mockBoard.Stop() - - // Configure client - clientAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to resolve client address: %v", err) - } - - clientConfig := tcp.NewClientConfig(clientAddr) - clientConfig.TryReconnect = false // Don't retry for this test - - // Start client connection in goroutine - clientDone := make(chan error, 1) - go func() { - err := transport.HandleClient(clientConfig, boardPort) - clientDone <- err - }() - - // Ensure cleanup - defer func() { - mockBoard.Stop() - // Wait for client to finish - select { - case <-clientDone: - case <-time.After(1 * time.Second): - // Client should exit when board stops - } - }() - - // Wait for connection - err = waitForCondition(func() bool { - return mockBoard.GetConnectionCount() > 0 - }, 2*time.Second, "Board should receive connection") - if err != nil { - t.Fatal(err) - } - - // Verify connection update was sent - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - return len(updates) > 0 && updates[len(updates)-1].IsConnected - }, 2*time.Second, "Should receive connection update") - if err != nil { - t.Fatal(err) - } - - // Stop the board to trigger disconnection - mockBoard.Stop() - - // Wait for client to detect disconnection - select { - case err := <-clientDone: - // Client should exit due to connection loss - if err == nil { - t.Error("Expected error from client due to disconnection") - } - case <-time.After(2 * time.Second): - t.Fatal("Client should have detected disconnection") - } - - // Verify disconnection update - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - return len(updates) >= 2 && !updates[len(updates)-1].IsConnected - }, 2*time.Second, "Should receive disconnection update") - if err != nil { - t.Fatal(err) - } -} - -func TestTransport_PacketSending(t *testing.T) { - transport, api := createTestTransport(t) - - // Setup - boardIP := "127.0.0.1" - boardPort := getAvailablePort(t) - target := abstraction.TransportTarget("TEST_BOARD") - packetID := abstraction.PacketId(100) - - transport.SetTargetIp(boardIP, target) - transport.SetIdTarget(packetID, target) - - // Create mock board - mockBoard := NewMockBoardServer(boardPort) - err := mockBoard.Start() - if err != nil { - t.Fatalf("Failed to start mock board: %v", err) - } - defer mockBoard.Stop() - - // Start client - clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - clientConfig := tcp.NewClientConfig(clientAddr) - clientConfig.TryReconnect = false - - clientDone := make(chan struct{}) - go func() { - defer close(clientDone) - transport.HandleClient(clientConfig, boardPort) - }() - - // Ensure cleanup - defer func() { - mockBoard.Stop() - select { - case <-clientDone: - case <-time.After(1 * time.Second): - } - }() - - // Wait for connection - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - return len(updates) > 0 && updates[len(updates)-1].Target == target && updates[len(updates)-1].IsConnected - }, 2*time.Second, "Should establish connection") - if err != nil { - t.Fatal(err) - } - - // Create and send packet - testPacket := data.NewPacket(packetID) - testPacket.SetTimestamp(time.Now()) - - err = transport.SendMessage(NewPacketMessage(testPacket)) - if err != nil { - t.Fatalf("Failed to send packet: %v", err) - } - - // Verify packet was received by board - err = waitForCondition(func() bool { - packets := mockBoard.GetReceivedPackets() - return len(packets) > 0 && packets[0].Id() == packetID - }, 2*time.Second, "Board should receive the packet") - if err != nil { - t.Fatal(err) - } - - // Verify no error notifications - notifications := api.GetNotifications() - for _, notification := range notifications { - if errNotif, ok := notification.(ErrorNotification); ok { - t.Errorf("Unexpected error notification: %v", errNotif.Err) - } - } -} - -func TestTransport_UnknownTarget(t *testing.T) { - transport, api := createTestTransport(t) - - // Try to send packet to unknown target - unknownPacket := data.NewPacket(999) // Unknown packet ID - unknownPacket.SetTimestamp(time.Now()) - - err := transport.SendMessage(NewPacketMessage(unknownPacket)) - if err == nil { - t.Fatal("Expected error when sending to unknown target") - } - - // Should be ErrUnrecognizedId - var unrecognizedErr ErrUnrecognizedId - if !ErrorAs(err, &unrecognizedErr) { - t.Errorf("Expected ErrUnrecognizedId, got %T: %v", err, err) - } else if unrecognizedErr.Id != abstraction.PacketId(999) { - t.Errorf("Expected packet ID 999, got %d", unrecognizedErr.Id) - } - - // Verify error notification - err = waitForCondition(func() bool { - notifications := api.GetNotifications() - if len(notifications) == 0 { - return false - } - _, isErrorNotif := notifications[len(notifications)-1].(ErrorNotification) - return isErrorNotif - }, 2*time.Second, "Should receive error notification") - if err != nil { - t.Fatal(err) - } -} - -func TestTransport_ReconnectionBehavior(t *testing.T) { - transport, api := createTestTransport(t) - - // Setup - boardIP := "127.0.0.1" - boardPort := getAvailablePort(t) - target := abstraction.TransportTarget("RECONNECT_BOARD") - - transport.SetTargetIp(boardIP, target) - transport.SetIdTarget(100, target) - - // Create mock board - mockBoard := NewMockBoardServer(boardPort) - err := mockBoard.Start() - if err != nil { - t.Fatalf("Failed to start mock board: %v", err) - } - - // Configure client with fast reconnection for testing - clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") - clientConfig := tcp.NewClientConfig(clientAddr) - clientConfig.TryReconnect = true - clientConfig.MaxConnectionRetries = 0 // Infinite retries - clientConfig.ConnectionBackoffFunction = tcp.NewExponentialBackoff( - 10*time.Millisecond, // Fast for testing - 1.5, - 100*time.Millisecond, - ) - - // Start client with proper cleanup - ctx, cancel := context.WithCancel(context.Background()) - clientConfig.Context = ctx - - clientDone := make(chan struct{}) - go func() { - defer close(clientDone) - transport.HandleClient(clientConfig, boardPort) - }() - - // Ensure cleanup happens - defer func() { - cancel() - mockBoard.Stop() - // Wait for client goroutine to finish - select { - case <-clientDone: - case <-time.After(1 * time.Second): - t.Log("Warning: client goroutine did not finish within timeout") - } - }() - - // Wait for initial connection - err = waitForCondition(func() bool { - return mockBoard.GetConnectionCount() > 0 - }, 3*time.Second, "Should establish initial connection") - if err != nil { - t.Fatal(err) - } - - // Verify connection update - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - return len(updates) > 0 && updates[len(updates)-1].IsConnected - }, 2*time.Second, "Should receive connection update") - if err != nil { - t.Fatal(err) - } - - // Simulate board restart - mockBoard.Stop() - - // Wait for disconnection detection - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - for i := len(updates) - 1; i >= 0; i-- { - if !updates[i].IsConnected && updates[i].Target == target { - return true - } - } - return false - }, 3*time.Second, "Should detect disconnection") - if err != nil { - t.Fatal(err) - } - - // Restart board - mockBoard = NewMockBoardServer(boardPort) - err = mockBoard.Start() - if err != nil { - t.Fatalf("Failed to restart mock board: %v", err) - } - - // Wait for reconnection - err = waitForCondition(func() bool { - return mockBoard.GetConnectionCount() > 0 - }, 5*time.Second, "Should reconnect to restarted board") - if err != nil { - t.Fatal(err) - } - - // Verify reconnection update - err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - if len(updates) < 3 { // Initial connect, disconnect, reconnect - return false - } - // Look for a connection update after the disconnection - for i := len(updates) - 1; i >= 0; i-- { - if updates[i].IsConnected && updates[i].Target == target { - // Make sure this is after a disconnection - for j := i - 1; j >= 0; j-- { - if !updates[j].IsConnected && updates[j].Target == target { - return true - } - } - } - } - return false - }, 5*time.Second, "Should receive reconnection update") - if err != nil { - t.Fatal(err) - } -} - -func TestHandleServer_AcceptsAndDispatches(t *testing.T) { - tr, api := createTestTransport(t) - target := abstraction.TransportTarget("SERVER_TARGET") - tr.SetTargetIp("127.0.0.1", target) - tr.SetIdTarget(100, target) - - local := getAvailablePort(t) - cfg := tcp.NewServerConfig() - ctx, cancel := context.WithCancel(context.Background()) - cfg.Context = ctx - defer cancel() - - done := make(chan struct{}) - go func() { - _ = tr.HandleServer(cfg, local) - close(done) - }() - - var conn net.Conn - var err error - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - conn, err = net.Dial("tcp", local) - if err == nil { - break - } - time.Sleep(20 * time.Millisecond) - } - if conn == nil { - t.Fatalf("failed to dial server: %v", err) - } - defer conn.Close() - - packet := data.NewPacket(100) - packet.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(packet) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - if _, err := conn.Write(buf.Bytes()); err != nil { - t.Fatalf("failed to write packet: %v", err) - } - - if err := waitForCondition(func() bool { - return len(api.GetNotifications()) > 0 - }, 2*time.Second, "Should receive notification from server connection"); err != nil { - t.Fatal(err) - } - - cancel() - select { - case <-done: - case <-time.After(500 * time.Millisecond): - } -} - -func TestHandleConversation_DispatchesAndStopsOnError(t *testing.T) { - tr, api := createTestTransport(t) - - pkt := data.NewPacket(100) - pkt.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(pkt) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - socket := network.Socket{ - SrcIP: "127.0.0.1", - SrcPort: 8000, - DstIP: "127.0.0.1", - DstPort: 8001, - } - - reader := bytes.NewReader(buf.Bytes()) - tr.handleConversation(socket, reader) - - if err := waitForCondition(func() bool { return len(api.GetNotifications()) >= 1 }, time.Second, "packet notification"); err != nil { - t.Fatal(err) - } - // After the first packet, DecodeNext will hit EOF and SendFault will result in an error notification. - if err := waitForCondition(func() bool { return len(api.GetNotifications()) >= 2 }, 2*time.Second, "error notification"); err != nil { - t.Fatal(err) - } -} - -// Helper function to mimic errors.As behavior -func ErrorAs(err error, target interface{}) bool { - switch target := target.(type) { - case *ErrUnrecognizedId: - if e, ok := err.(ErrUnrecognizedId); ok { - *target = e - return true - } - case *ErrConnClosed: - if e, ok := err.(ErrConnClosed); ok { - *target = e - return true - } - } - return false -}