peerpigeon
Version: 
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
1,199 lines (1,012 loc) • 46.2 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { PeerConnection } from './PeerConnection.js';
import { environmentDetector } from './EnvironmentDetector.js';
import DebugLogger from './DebugLogger.js';
/**
 * Manages individual peer connections, timeouts, and connection attempts
 */
export class ConnectionManager extends EventEmitter {
  constructor(mesh) {
    super();
    this.mesh = mesh;
    this.debug = DebugLogger.create('ConnectionManager');
    this.peers = new Map();
    this.connectionAttempts = new Map();
    this.pendingIceCandidates = new Map();
    this.disconnectionInProgress = new Set();
    this.cleanupInProgress = new Set();
    this.lastConnectionAttempt = new Map(); // Track last attempt time per peer
    // Renegotiation management to prevent conflicts
    this.activeRenegotiations = new Set();
    this.renegotiationQueue = new Map();
    this.maxConcurrentRenegotiations = 1; // Only allow 1 renegotiation at a time
    // Configuration
    this.maxConnectionAttempts = 3;
    this.retryDelay = 500; // Faster retry - 500ms between attempts to same peer
    // Start periodic cleanup of stale peers
    this.startPeriodicCleanup();
    // Start monitoring for stuck connections
    this.startStuckConnectionMonitoring();
    
    // Set up mesh-level event listeners for crypto-gated media
    this.setupMeshEventListeners();
  }
  
  /**
   * Set up event listeners for mesh-level events
   */
  setupMeshEventListeners() {
    // Listen for successful key exchanges to enable media sharing
    this.mesh.addEventListener('peerKeyAdded', (event) => {
      this.handlePeerKeyAdded(event.peerId);
    });
  }
  
  /**
   * Handle when a peer's crypto key is successfully added
   * @param {string} peerId - The peer ID whose key was added
   */
  async handlePeerKeyAdded(peerId) {
    this.debug.log(`🔐 Key added for ${peerId.substring(0, 8)}... - crypto verification complete`);
    
    // NOTE: We do NOT automatically enable remote streams here
    // Media streams (both local and remote) must be manually invoked by the user via the "Start Media" button
    // This ensures complete control over when ANY media streams are allowed
    this.debug.log(`🔐 Crypto verified for ${peerId.substring(0, 8)}... - user must manually invoke media to enable streams`);
  }
  async connectToPeer(targetPeerId) {
    this.debug.log(`connectToPeer called for ${targetPeerId.substring(0, 8)}...`);
    // Enhanced duplicate connection prevention
    if (this.peers.has(targetPeerId)) {
      this.debug.log(`Already connected to ${targetPeerId.substring(0, 8)}...`);
      return;
    }
    // Check if we're already attempting through PeerDiscovery
    if (this.mesh.peerDiscovery.isAttemptingConnection(targetPeerId)) {
      this.debug.log(`Already attempting connection to ${targetPeerId.substring(0, 8)}... via PeerDiscovery`);
      return;
    }
    // INITIATOR LOGIC: Use deterministic peer ID comparison to prevent race conditions
    // Only become initiator if our peer ID is lexicographically greater than target's
    const shouldBeInitiator = this.mesh.peerId > targetPeerId;
    if (!shouldBeInitiator) {
      this.debug.log(`🔄 INITIATOR LOGIC: Not becoming initiator for ${targetPeerId.substring(0, 8)}... (our ID: ${this.mesh.peerId.substring(0, 8)}... is smaller)`);
      return; // Let the other peer initiate
    }
    this.debug.log(`🔄 INITIATOR LOGIC: Becoming initiator for ${targetPeerId.substring(0, 8)}... (our ID: ${this.mesh.peerId.substring(0, 8)}... is greater)`);
    if (!this.mesh.canAcceptMorePeers()) {
      this.debug.log(`Cannot connect to ${targetPeerId.substring(0, 8)}... (max peers reached: ${this.mesh.maxPeers})`);
      return;
    }
    // Check retry cooldown (only apply after first attempt)
    const now = Date.now();
    const attempts = this.connectionAttempts.get(targetPeerId) || 0;
    if (attempts > 0) {
      const lastAttempt = this.lastConnectionAttempt.get(targetPeerId) || 0;
      // Use shorter delay for isolated peers to help them connect faster
      const connectedCount = this.getConnectedPeerCount();
      const retryDelay = connectedCount === 0 ? 200 : this.retryDelay; // 200ms for isolated peers, 500ms otherwise
      if (now - lastAttempt < retryDelay) {
        const remaining = retryDelay - (now - lastAttempt);
        this.debug.log(`Connection to ${targetPeerId.substring(0, 8)}... on cooldown (${Math.round(remaining / 1000)}s remaining, isolated: ${connectedCount === 0})`);
        return;
      }
    }
    // Check connection attempt count
    if (attempts >= this.maxConnectionAttempts) {
      this.mesh.emit('statusChanged', { type: 'warning', message: `Max connection attempts reached for ${targetPeerId.substring(0, 8)}...` });
      this.mesh.peerDiscovery.removeDiscoveredPeer(targetPeerId);
      return;
    }
    this.debug.log(`Starting connection to ${targetPeerId.substring(0, 8)}... (attempt ${attempts + 1})`);
    this.connectionAttempts.set(targetPeerId, attempts + 1);
    this.lastConnectionAttempt.set(targetPeerId, now);
    this.mesh.peerDiscovery.trackConnectionAttempt(targetPeerId);
    try {
      this.debug.log(`Creating PeerConnection for ${targetPeerId.substring(0, 8)}...`);
      // SECURITY: NO automatic media sharing - all media must be manually invoked
      const options = {
        localStream: null, // Always null - media must be manually added later
        // ALWAYS enable both audio and video transceivers for maximum compatibility
        // This allows peers to receive media even if they don't have media when connecting
        enableAudio: true,
        enableVideo: true
        // allowRemoteStreams defaults to false - streams only invoked when user clicks "Start Media"
      };
      this.debug.log(`🔄 INITIATOR SETUP: Creating PeerConnection(${targetPeerId.substring(0, 8)}..., isInitiator=true)`);
      const peerConnection = new PeerConnection(targetPeerId, true, options);
      // Set up event handlers BEFORE creating connection to catch all events
      this.setupPeerConnectionHandlers(peerConnection);
      this.peers.set(targetPeerId, peerConnection);
      this.debug.log(`Creating WebRTC connection for ${targetPeerId.substring(0, 8)}...`);
      await peerConnection.createConnection();
      this.debug.log(`Creating offer for ${targetPeerId.substring(0, 8)}...`);
      const offer = await peerConnection.createOffer();
      this.debug.log(`Offer created for ${targetPeerId.substring(0, 8)}...`, {
        type: offer.type,
        sdpLength: offer.sdp?.length || 0,
        hasAudio: offer.sdp?.includes('m=audio') || false,
        hasVideo: offer.sdp?.includes('m=video') || false
      });
      this.debug.log(`Sending offer to ${targetPeerId.substring(0, 8)}...`);
      await this.mesh.sendSignalingMessage({
        type: 'offer',
        data: offer
      }, targetPeerId);
      this.mesh.emit('statusChanged', { type: 'info', message: `Offer sent to ${targetPeerId.substring(0, 8)}...` });
    } catch (error) {
      this.debug.error('Failed to connect to peer:', error);
      this.mesh.emit('statusChanged', { type: 'error', message: `Failed to connect to ${targetPeerId.substring(0, 8)}...: ${error.message}` });
      this.cleanupFailedConnection(targetPeerId);
    }
  }
  cleanupFailedConnection(peerId) {
    this.debug.log(`Cleaning up failed connection for ${peerId.substring(0, 8)}...`);
    // Remove peer connection
    let peerRemoved = false;
    if (this.peers.has(peerId)) {
      const peer = this.peers.get(peerId);
      const status = peer.getStatus();
      this.debug.log(`Removing peer ${peerId.substring(0, 8)}... with status: ${status}`);
      try {
        if (typeof peer.markAsFailed === 'function') {
          peer.markAsFailed('failed');
        }
        peer.close();
      } catch (error) {
        this.debug.error('Error closing failed connection:', error);
      }
      this.peers.delete(peerId);
      peerRemoved = true;
      this.debug.log(`Successfully removed peer ${peerId.substring(0, 8)}... from peers Map`);
    } else {
      this.debug.log(`Peer ${peerId.substring(0, 8)}... was not in peers Map`);
    }
    // Clean up related data
    this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
    this.pendingIceCandidates.delete(peerId);
    // Always emit peersUpdated if we removed a peer or to force UI refresh
    if (peerRemoved) {
      this.debug.log(`Emitting peersUpdated after removing ${peerId.substring(0, 8)}...`);
      this.emit('peersUpdated');
    }
  }
  cleanupRaceCondition(peerId) {
    // Remove peer connection but preserve connection attempts
    if (this.peers.has(peerId)) {
      const peer = this.peers.get(peerId);
      try {
        peer.close();
      } catch (error) {
        this.debug.error('Error closing race condition connection:', error);
      }
      this.peers.delete(peerId);
    }
    // Don't clear connection attempts or discovery data - just the active connection
    this.pendingIceCandidates.delete(peerId);
    this.emit('peersUpdated');
  }
  setupPeerConnectionHandlers(peerConnection) {
    peerConnection.addEventListener('iceCandidate', async (event) => {
      try {
        this.debug.log('Sending ICE candidate to', event.peerId);
        await this.mesh.sendSignalingMessage({
          type: 'ice-candidate',
          data: event.candidate
        }, event.peerId);
      } catch (error) {
        this.debug.error('Failed to send ICE candidate:', error);
      }
    });
    peerConnection.addEventListener('connected', (event) => {
      this.debug.log(`[EVENT] Connected event received from ${event.peerId.substring(0, 8)}...`);
      // Reset connection attempts on successful connection
      this.connectionAttempts.delete(event.peerId);
      // Don't emit peerConnected here - wait for data channel to be ready
      this.mesh.emit('statusChanged', { type: 'info', message: `WebRTC connected to ${event.peerId.substring(0, 8)}...` });
      this.mesh.peerDiscovery.clearConnectionAttempt(event.peerId);
      this.mesh.peerDiscovery.updateDiscoveryTimestamp(event.peerId);
      this.emit('peersUpdated');
    });
    peerConnection.addEventListener('disconnected', (event) => {
      this.mesh.emit('statusChanged', { type: 'info', message: `Disconnected from ${event.peerId.substring(0, 8)}...` });
      this.handlePeerDisconnection(event.peerId, event.reason);
    });
    peerConnection.addEventListener('dataChannelOpen', (event) => {
      this.debug.log(`[EVENT] DataChannelOpen event received from ${event.peerId.substring(0, 8)}...`);
      this.mesh.emit('statusChanged', { type: 'info', message: `Data channel ready with ${event.peerId.substring(0, 8)}...` });
      this.emit('peersUpdated');
      // Track successful connections to reset isolation timer
      if (this.mesh.peerDiscovery) {
        this.mesh.peerDiscovery.onConnectionEstablished();
      }
      // Automatically initiate key exchange when crypto is enabled
      if (this.mesh.cryptoManager) {
        // Check if we already have this peer's key to avoid duplicate exchanges
        const hasExistingKey = this.mesh.cryptoManager.peerKeys.has(event.peerId);
        if (!hasExistingKey) {
          this.debug.log(`🔐 Automatically exchanging keys with newly connected peer ${event.peerId.substring(0, 8)}...`);
          // PERFORMANCE: Defer key exchange to prevent blocking data channel establishment
          setTimeout(() => {
            this.mesh.exchangeKeysWithPeer(event.peerId).catch(error => {
              this.debug.error(`🔐 Failed to exchange keys with ${event.peerId.substring(0, 8)}:`, error);
            });
          }, 0);
        } else {
          this.debug.log(`🔐 Skipping key exchange with ${event.peerId.substring(0, 8)}... - key already exists`);
        }
      }
      this.mesh.emit('peerConnected', { peerId: event.peerId });
    });
    peerConnection.addEventListener('message', (event) => {
      this.handleIncomingMessage(event.message, event.peerId);
    });
    peerConnection.addEventListener('remoteStream', (event) => {
      this.debug.log(`[EVENT] Remote stream received from ${event.peerId.substring(0, 8)}...`);
      this.emit('remoteStream', event);
      // DISABLED: Media forwarding causes cascade renegotiation issues with 3+ peers
      // Each peer should manage their own direct streams to avoid conflicts
      // this._forwardStreamToOtherPeers(event.stream, event.peerId);
      this.debug.log('🔄 MEDIA FORWARDING: Disabled to prevent renegotiation conflicts with 3+ peers');
    });
    peerConnection.addEventListener('renegotiationNeeded', async (event) => {
      this.debug.log(`🔄 Renegotiation needed for ${event.peerId.substring(0, 8)}...`);
      // SMART RENEGOTIATION: Queue renegotiations to prevent conflicts
      if (this.activeRenegotiations.size >= this.maxConcurrentRenegotiations) {
        this.debug.log(`🔄 QUEUE: Renegotiation for ${event.peerId.substring(0, 8)}... queued (${this.activeRenegotiations.size} active)`);
        this.renegotiationQueue.set(event.peerId, event);
        return;
      }
      await this._performRenegotiation(peerConnection, event);
    });
    // ...existing code...
  }
  handlePeerDisconnection(peerId, reason) {
    // Prevent duplicate disconnection handling
    if (this.disconnectionInProgress.has(peerId)) {
      this.debug.log(`Disconnection already in progress for ${peerId.substring(0, 8)}..., skipping duplicate`);
      return;
    }
    this.debug.log(`Handling peer disconnection: ${peerId.substring(0, 8)}... (${reason})`);
    // Mark disconnection in progress
    this.disconnectionInProgress.add(peerId);
    try {
      // Clear all related data
      if (this.peers.has(peerId)) {
        const peerConnection = this.peers.get(peerId);
        try {
          peerConnection.close();
        } catch (error) {
          this.debug.error('Error closing peer connection:', error);
        }
        this.peers.delete(peerId);
      }
      this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
      this.pendingIceCandidates.delete(peerId);
      // Clean up eviction tracking
      this.mesh.evictionManager.clearEvictionTracking(peerId);
      // Don't remove from discovered peers immediately - let it timeout naturally
      // unless it's a goodbye or explicit removal
      if (reason === 'left network' || reason === 'manually removed') {
        this.mesh.peerDiscovery.removeDiscoveredPeer(peerId);
        this.connectionAttempts.delete(peerId);
      } else if (reason === 'connection failed' || reason === 'connection disconnected' || reason === 'ICE connection closed') {
        // For connection failures, clear the attempt so we can retry later
        this.connectionAttempts.delete(peerId);
        this.debug.log(`Cleared connection attempt for ${peerId.substring(0, 8)}... due to ${reason} - will retry later`);
      }
      this.mesh.emit('peerDisconnected', { peerId, reason });
      this.emit('peersUpdated');
      // Only trigger optimization if we're significantly under capacity
      const connectedCount = this.getConnectedPeerCount();
      const needsOptimization = connectedCount === 0; // Only optimize if completely disconnected
      if (needsOptimization && this.mesh.autoDiscovery && this.mesh.peerDiscovery.getDiscoveredPeers().length > 0) {
        this.debug.log(`Completely disconnected (${connectedCount}/${this.mesh.maxPeers}), scheduling mesh optimization`);
        setTimeout(() => {
          // Check if we still need optimization
          const currentCount = this.getConnectedPeerCount();
          if (currentCount === 0) {
            this.debug.log(`Still completely disconnected (${currentCount}/${this.mesh.maxPeers}), attempting optimization`);
            this.mesh.peerDiscovery.optimizeMeshConnections(this.peers);
          } else {
            this.debug.log(`Connection recovered (${currentCount}/${this.mesh.maxPeers}), skipping optimization`);
          }
        }, 500); // Wait 500ms before optimizing - faster response
      } else {
        this.debug.log(`Peer count appropriate at ${connectedCount}/${this.mesh.maxPeers}, no optimization needed`);
      }
    } finally {
      // Always clean up the disconnection tracking
      this.disconnectionInProgress.delete(peerId);
    }
  }
  disconnectAllPeers() {
    this.peers.forEach((peerConnection, peerId) => {
      peerConnection.close();
      this.mesh.emit('peerDisconnected', { peerId, reason: 'mesh disconnected' });
    });
  }
  disconnectPeer(peerId, reason) {
    this.handlePeerDisconnection(peerId, reason);
  }
  removePeer(peerId) {
    this.mesh.peerDiscovery.removeDiscoveredPeer(peerId);
    this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
    this.connectionAttempts.delete(peerId);
    if (this.peers.has(peerId)) {
      const peer = this.peers.get(peerId);
      if (peer.connection) {
        peer.connection.close();
      }
      this.peers.delete(peerId);
      this.mesh.emit('peerDisconnected', { peerId, reason: 'manually removed' });
    }
    this.emit('peersUpdated');
  }
  canAcceptMorePeers() {
    // Count connected peers first (these are guaranteed slots)
    const connectedCount = this.getConnectedPeerCount();
    // If we have room for connected peers, always accept
    if (connectedCount < this.mesh.maxPeers) {
      return true;
    }
    // If at max connected peers, check if we have stale peers we can evict
    const stalePeerCount = this.getStalePeerCount();
    const totalPeerCount = this.peers.size;
    // Accept if we have stale peers that can be cleaned up
    if (stalePeerCount > 0 && totalPeerCount >= this.mesh.maxPeers) {
      this.debug.log(`At capacity (${connectedCount}/${this.mesh.maxPeers} connected, ${totalPeerCount} total) but have ${stalePeerCount} stale peers that can be evicted`);
      return true;
    }
    // Reject if we're at capacity with all viable peers
    this.debug.log(`Cannot accept more peers: ${connectedCount}/${this.mesh.maxPeers} connected, ${totalPeerCount} total peers in Map`);
    return false;
  }
  /**
     * Count peers that are in stale/non-viable states
     */
  getStalePeerCount() {
    const now = Date.now();
    const STALE_THRESHOLD = 45000; // 45 seconds (shorter than cleanup threshold)
    return Array.from(this.peers.values()).filter(peerConnection => {
      const status = peerConnection.getStatus();
      const connectionAge = now - peerConnection.connectionStartTime;
      return connectionAge > STALE_THRESHOLD &&
                   (status === 'failed' || status === 'disconnected' || status === 'closed');
    }).length;
  }
  getConnectedPeerCount() {
    return Array.from(this.peers.values()).filter(peerConnection =>
      peerConnection.getStatus() === 'connected'
    ).length;
  }
  getConnectedPeers() {
    return Array.from(this.peers.values()).filter(peerConnection =>
      peerConnection.getStatus() === 'connected'
    );
  }
  getPeers() {
    return Array.from(this.peers.entries()).map(([peerId, peerConnection]) => ({
      peerId,
      status: peerConnection.getStatus(),
      isInitiator: peerConnection.isInitiator,
      connectionStartTime: peerConnection.connectionStartTime
    }));
  }
  hasPeer(peerId) {
    return this.peers.has(peerId);
  }
  getPeer(peerId) {
    return this.peers.get(peerId);
  }
  sendMessage(content) {
    if (!content || typeof content !== 'string') {
      this.debug.error('Invalid message content:', content);
      return 0;
    }
    // Use gossip protocol to broadcast messages throughout the mesh network
    this.debug.log(`Broadcasting message via gossip protocol: "${content}"`);
    const messageId = this.mesh.gossipManager.broadcastMessage(content, 'chat');
    if (messageId) {
      // Return the number of directly connected peers for UI feedback
      // (the message will propagate to the entire network via gossip)
      const connectedCount = this.getConnectedPeerCount();
      this.debug.log(`Message broadcasted via gossip to ${connectedCount} directly connected peer(s), will propagate to entire network`);
      return connectedCount;
    } else {
      this.debug.error('Failed to broadcast message via gossip protocol');
      return 0;
    }
  }
  /**
     * Send a message directly to a specific peer via data channel
     * @param {string} peerId - The peer ID to send to
     * @param {Object} message - The message object to send
     * @returns {boolean} - True if message was sent successfully
     */
  sendDirectMessage(peerId, message) {
    const peerConnection = this.peers.get(peerId);
    if (!peerConnection) {
      this.debug.warn(`Cannot send direct message to ${peerId?.substring(0, 8)}: peer not connected`);
      return false;
    }
    try {
      this.debug.log(`📤 Sending direct message to ${peerId?.substring(0, 8)}:`, message);
      peerConnection.sendMessage(message);
      return true;
    } catch (error) {
      this.debug.error(`Failed to send direct message to ${peerId?.substring(0, 8)}:`, error);
      return false;
    }
  }
  async handleIceCandidate(candidate, fromPeerId) {
    this.debug.log('Handling ICE candidate from', fromPeerId);
    const peerConnection = this.peers.get(fromPeerId);
    if (peerConnection) {
      try {
        await peerConnection.handleIceCandidate(candidate);
      } catch (error) {
        this.debug.error('Failed to add ICE candidate:', error);
      }
    } else {
      // No peer connection exists yet - buffer the candidate for when it's created
      this.debug.log('Buffering ICE candidate for', fromPeerId, '(no peer connection yet)');
      if (!this.pendingIceCandidates.has(fromPeerId)) {
        this.pendingIceCandidates.set(fromPeerId, []);
      }
      this.pendingIceCandidates.get(fromPeerId).push(candidate);
    }
  }
  async processPendingIceCandidates(peerId) {
    const candidates = this.pendingIceCandidates.get(peerId);
    if (candidates && candidates.length > 0) {
      this.debug.log(`Processing ${candidates.length} buffered ICE candidates for`, peerId);
      const peerConnection = this.peers.get(peerId);
      if (peerConnection) {
        for (const candidate of candidates) {
          try {
            await peerConnection.handleIceCandidate(candidate);
          } catch (error) {
            this.debug.error('Failed to add buffered ICE candidate:', error);
          }
        }
        // Clear the buffer after processing
        this.pendingIceCandidates.delete(peerId);
      }
    }
  }
  cleanup() {
    // Stop periodic cleanup
    this.stopPeriodicCleanup();
    this.peers.clear();
    this.connectionAttempts.clear();
    this.pendingIceCandidates.clear();
    this.disconnectionInProgress.clear();
    this.cleanupInProgress.clear();
    this.lastConnectionAttempt.clear();
  }
  /**
     * Start periodic cleanup of stale peer connections
     */
  startPeriodicCleanup() {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }
    // Run cleanup every 30 seconds - environment-aware timer
    if (environmentDetector.isBrowser) {
      this.cleanupInterval = window.setInterval(() => {
        this.cleanupStalePeers();
      }, 30000);
    } else {
      this.cleanupInterval = setInterval(() => {
        this.cleanupStalePeers();
      }, 30000);
    }
  }
  /**
     * Stop periodic cleanup
     */
  stopPeriodicCleanup() {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
      this.cleanupInterval = null;
    }
  }
  /**
     * Clean up peers that are in non-viable states for too long
     */
  cleanupStalePeers() {
    const now = Date.now();
    const STALE_THRESHOLD = 60000; // 60 seconds
    const DISCONNECTED_THRESHOLD = 5000; // 5 seconds for disconnected peers
    const peersToCleanup = [];
    this.peers.forEach((peerConnection, peerId) => {
      const status = peerConnection.getStatus();
      const connectionAge = now - peerConnection.connectionStartTime;
      // Immediately clean up disconnected peers
      if (status === 'disconnected' && connectionAge > DISCONNECTED_THRESHOLD) {
        this.debug.log(`Disconnected peer detected: ${peerId.substring(0, 8)}... (status: ${status}, age: ${Math.round(connectionAge / 1000)}s)`);
        peersToCleanup.push(peerId);
      } else if (connectionAge > STALE_THRESHOLD) {
        if (status === 'connecting' || status === 'channel-connecting' ||
                    status === 'failed' || status === 'closed') {
          this.debug.log(`Stale peer detected: ${peerId.substring(0, 8)}... (status: ${status}, age: ${Math.round(connectionAge / 1000)}s)`);
          peersToCleanup.push(peerId);
        }
      }
    });
    if (peersToCleanup.length > 0) {
      this.debug.log(`Cleaning up ${peersToCleanup.length} stale peer(s)`);
      peersToCleanup.forEach(peerId => {
        this.cleanupFailedConnection(peerId);
      });
    }
  }
  /**
     * Force cleanup of peers that are not in connected state (for debugging)
     */
  forceCleanupInvalidPeers() {
    this.debug.log('Force cleaning up peers not in connected state...');
    const peersToRemove = [];
    this.peers.forEach((peerConnection, peerId) => {
      const status = peerConnection.getStatus();
      if (status !== 'connected') {
        this.debug.log(`Found peer ${peerId.substring(0, 8)}... in invalid state: ${status}`);
        peersToRemove.push(peerId);
      }
    });
    peersToRemove.forEach(peerId => {
      this.debug.log(`Force removing peer ${peerId.substring(0, 8)}...`);
      this.cleanupFailedConnection(peerId);
    });
    if (peersToRemove.length > 0) {
      this.debug.log(`Force cleaned up ${peersToRemove.length} invalid peers`);
      this.emit('peersUpdated');
    }
    return peersToRemove.length;
  }
  /**
     * Get a summary of all peer states for debugging
     */
  getPeerStateSummary() {
    const summary = {
      total: this.peers.size,
      connected: 0,
      connecting: 0,
      channelConnecting: 0,
      failed: 0,
      disconnected: 0,
      closed: 0,
      other: 0,
      stale: this.getStalePeerCount()
    };
    this.peers.forEach((peerConnection) => {
      const status = peerConnection.getStatus();
      switch (status) {
        case 'connected':
          summary.connected++;
          break;
        case 'connecting':
          summary.connecting++;
          break;
        case 'channel-connecting':
          summary.channelConnecting++;
          break;
        case 'failed':
          summary.failed++;
          break;
        case 'disconnected':
          summary.disconnected++;
          break;
        case 'closed':
          summary.closed++;
          break;
        default:
          summary.other++;
      }
    });
    return summary;
  }
  getDetailedPeerStatus() {
    const peerStatuses = {};
    this.peers.forEach((peerConnection, peerId) => {
      peerStatuses[peerId.substring(0, 8) + '...'] = {
        status: peerConnection.getStatus(),
        isInitiator: peerConnection.isInitiator,
        dataChannelReady: peerConnection.dataChannelReady,
        connectionStartTime: peerConnection.connectionStartTime,
        connectionState: peerConnection.connection?.connectionState,
        iceConnectionState: peerConnection.connection?.iceConnectionState
      };
    });
    return peerStatuses;
  }
  // Get all peer connections
  getAllConnections() {
    return Array.from(this.peers.values());
  }
  /**
     * Route incoming messages based on their type
     */
  handleIncomingMessage(message, fromPeerId) {
    if (!message || typeof message !== 'object') {
      this.debug.warn('Received invalid message from', fromPeerId?.substring(0, 8));
      return;
    }
    // Define message types that should be filtered from peer-readable messages
    // These messages are processed but not emitted as regular messages to UI/applications
    const filteredMessageTypes = new Set([
      'signaling-relay',
      'peer-announce-relay', 
      'bootstrap-keepalive',
      'client-peer-announcement',
      'cross-bootstrap-signaling'
    ]);
    // Check if this message type should be filtered from peer-readable messages
    const isFilteredMessage = filteredMessageTypes.has(message.type);
    if (isFilteredMessage) {
      this.debug.log(`🔇 FILTER: Processing filtered message type '${message.type}' from ${fromPeerId?.substring(0, 8)} (not emitted to UI)`);
    }
    // Route based on message type
    switch (message.type) {
      case 'gossip':
        // Gossip protocol messages (async call, but we don't wait for it)
        this.mesh.gossipManager.handleGossipMessage(message, fromPeerId).catch(error => {
          this.debug.error('Error handling gossip message:', error);
        });
        break;
      case 'eviction':
        // Handle eviction notices
        this.handleEvictionMessage(message, fromPeerId);
        break;
      case 'dht':
        // WebDHT messages
        if (this.mesh.webDHT) {
          this.mesh.webDHT.handleMessage(message, fromPeerId);
        }
        break;
      case 'renegotiation-offer':
        // Handle renegotiation offers from peers
        this.handleRenegotiationOffer(message, fromPeerId);
        break;
      case 'renegotiation-answer':
        // Handle renegotiation answers from peers
        this.handleRenegotiationAnswer(message, fromPeerId);
        break;
      case 'signaling':
        // Handle wrapped signaling messages sent via mesh
        this.debug.log(`🔄 MESH SIGNALING: Received ${message.data?.type} from ${fromPeerId?.substring(0, 8)}...`);
        if (message.data && message.data.type) {
          // Unwrap and handle the signaling message
          const signalingMessage = {
            type: message.data.type,
            data: message.data.data,
            fromPeerId: message.fromPeerId || fromPeerId,
            targetPeerId: this.mesh.peerId,
            timestamp: message.timestamp
          };
          // Route to signaling handler
          this.mesh.signalingHandler.handleSignalingMessage(signalingMessage);
        }
        break;
      case 'signaling-relay':
        // Process signaling relay messages but don't emit as peer-readable
        this.debug.log(`🔇 FILTER: Processing signaling-relay from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
        // Handle the signaling relay internally - extract and process the actual signaling message
        if (message.data && message.targetPeerId === this.mesh.peerId) {
          this.mesh.signalingHandler.handleSignalingMessage({
            type: message.data.type,
            data: message.data.data,
            fromPeerId: message.fromPeerId || fromPeerId,
            targetPeerId: message.targetPeerId,
            timestamp: message.timestamp
          });
        }
        return; // Early return to prevent fallback to gossip handler
      case 'peer-announce-relay':
        // Process peer announce relay messages but don't emit as peer-readable
        this.debug.log(`🔇 FILTER: Processing peer-announce-relay from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
        // Handle the peer announcement internally
        if (message.data && this.mesh.signalingHandler) {
          this.mesh.signalingHandler.handlePeerAnnouncement(message.data, fromPeerId);
        }
        return; // Early return to prevent fallback to gossip handler
      case 'bootstrap-keepalive':
        // Process bootstrap keepalive messages but don't emit as peer-readable
        this.debug.log(`🔇 FILTER: Processing bootstrap-keepalive from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
        // Handle keepalive internally - update peer discovery timestamps
        if (this.mesh.peerDiscovery) {
          this.mesh.peerDiscovery.updateDiscoveryTimestamp(fromPeerId);
        }
        return; // Early return to prevent fallback to gossip handler
      case 'client-peer-announcement':
        // Process client peer announcement messages but don't emit as peer-readable
        this.debug.log(`🔇 FILTER: Processing client-peer-announcement from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
        // Handle client peer announcement internally
        if (message.clientPeerId && this.mesh.signalingHandler) {
          this.mesh.signalingHandler.handlePeerAnnouncement(message.clientPeerId);
        }
        return; // Early return to prevent fallback to gossip handler
      case 'cross-bootstrap-signaling':
        // Process cross-bootstrap signaling messages but don't emit as peer-readable
        this.debug.log(`🔇 FILTER: Processing cross-bootstrap-signaling from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
        // Handle cross-bootstrap signaling internally
        if (message.originalMessage && message.targetPeerId === this.mesh.peerId && this.mesh.signalingHandler) {
          // Extract and process the wrapped signaling message
          this.mesh.signalingHandler.handleSignalingMessage({
            type: message.originalMessage.type,
            data: message.originalMessage.data,
            fromPeerId: message.originalMessage.fromPeerId || fromPeerId,
            targetPeerId: message.targetPeerId,
            timestamp: message.originalMessage.timestamp || message.timestamp
          });
        }
        return; // Early return to prevent fallback to gossip handler
      default:
        // For non-filtered message types, check if they should be emitted
        if (!isFilteredMessage) {
          // Unknown message type - try gossip as fallback for backward compatibility
          this.debug.warn(`Unknown message type '${message.type}' from ${fromPeerId?.substring(0, 8)}, trying gossip handler`);
          this.mesh.gossipManager.handleGossipMessage(message, fromPeerId).catch(error => {
            this.debug.error('Error handling unknown message as gossip:', error);
          });
        } else {
          // This is a filtered message type that doesn't have a specific handler
          this.debug.log(`🔇 FILTER: Filtered message type '${message.type}' processed but not emitted`);
        }
        break;
    }
  }
  /**
     * Handle eviction messages
     */
  handleEvictionMessage(message, fromPeerId) {
    this.debug.log(`Received eviction notice from ${fromPeerId?.substring(0, 8)}: ${message.reason}`);
    // Emit eviction event for UI notification
    this.mesh.emit('peerEvicted', {
      peerId: fromPeerId,
      reason: message.reason,
      initiatedByPeer: true
    });
    // Close the connection gracefully
    const peerConnection = this.peers.get(fromPeerId);
    if (peerConnection) {
      peerConnection.close();
      this.peers.delete(fromPeerId);
    }
  }
  /**
   * Perform a single renegotiation with conflict prevention
   * @private
   */
  async _performRenegotiation(peerConnection, event) {
    const peerId = event.peerId;
    
    // Mark this renegotiation as active
    this.activeRenegotiations.add(peerId);
    
    try {
      this.debug.log(`🔄 ACTIVE: Starting renegotiation for ${peerId.substring(0, 8)}... (${this.activeRenegotiations.size} active)`);
      // Check connection state - allow renegotiation for stable connections or those stuck in "have-local-offer"
      const signalingState = peerConnection.connection.signalingState;
      if (signalingState !== 'stable' && signalingState !== 'have-local-offer') {
        this.debug.log(`Skipping renegotiation for ${peerId.substring(0, 8)}... - connection in unsupported state (${signalingState})`);
        return;
      }
      // Additional check for connection state
      if (peerConnection.connection.connectionState !== 'connected') {
        this.debug.log(`Skipping renegotiation for ${peerId.substring(0, 8)}... - not connected (${peerConnection.connection.connectionState})`);
        return;
      }
      this.debug.log(`🔄 Creating renegotiation offer for ${peerId.substring(0, 8)}... (signaling state: ${signalingState})`);
      // Create new offer for renegotiation
      const offer = await peerConnection.connection.createOffer();
      this.debug.log('🔍 RENEGOTIATION OFFER SDP DEBUG:');
      this.debug.log(`   SDP length: ${offer.sdp.length}`);
      this.debug.log(`   Contains video: ${offer.sdp.includes('m=video')}`);
      this.debug.log(`   Contains audio: ${offer.sdp.includes('m=audio')}`);
      await peerConnection.connection.setLocalDescription(offer);
      // Send the renegotiation offer
      await this.mesh.sendSignalingMessage({
        type: 'renegotiation-offer',
        data: offer
      }, peerId);
      this.debug.log(`✅ ACTIVE: Sent renegotiation offer to ${peerId.substring(0, 8)}...`);
    } catch (error) {
      this.debug.error(`❌ ACTIVE: Failed to renegotiate with ${peerId.substring(0, 8)}...`, error);
    } finally {
      // Always clean up and process queue
      this.activeRenegotiations.delete(peerId);
      this.debug.log(`🔄 ACTIVE: Completed renegotiation for ${peerId.substring(0, 8)}... (${this.activeRenegotiations.size} active)`);
      
      // Process next queued renegotiation
      this._processRenegotiationQueue();
    }
  }
  /**
   * Process the next renegotiation in the queue
   * @private
   */
  _processRenegotiationQueue() {
    if (this.activeRenegotiations.size >= this.maxConcurrentRenegotiations || this.renegotiationQueue.size === 0) {
      return;
    }
    // Get next queued renegotiation
    const [nextPeerId, nextEvent] = this.renegotiationQueue.entries().next().value;
    this.renegotiationQueue.delete(nextPeerId);
    const peerConnection = this.peers.get(nextPeerId);
    if (peerConnection) {
      this.debug.log(`🔄 QUEUE: Processing queued renegotiation for ${nextPeerId.substring(0, 8)}...`);
      this._performRenegotiation(peerConnection, nextEvent);
    }
  }
  /**
   * Handle renegotiation offers from peers
   */
  async handleRenegotiationOffer(message, fromPeerId) {
    this.debug.log(`🔄 Handling renegotiation offer via mesh from ${fromPeerId.substring(0, 8)}...`);
    // Find the existing peer connection
    const peerConnection = this.peers.get(fromPeerId);
    if (!peerConnection) {
      this.debug.error(`No peer connection found for renegotiation from ${fromPeerId.substring(0, 8)}...`);
      return;
    }
    try {
      // Handle the renegotiation offer and get the answer
      const answer = await peerConnection.handleOffer(message.data);
      this.debug.log(`✅ Renegotiation offer processed, sending answer to ${fromPeerId.substring(0, 8)}...`);
      // Send the answer back to complete the renegotiation handshake
      await this.mesh.sendSignalingMessage({
        type: 'renegotiation-answer',
        data: answer
      }, fromPeerId);
      this.debug.log(`✅ Renegotiation completed via mesh with ${fromPeerId.substring(0, 8)}...`);
    } catch (error) {
      this.debug.error(`❌ Failed to handle renegotiation offer via mesh from ${fromPeerId.substring(0, 8)}...`, error);
    }
  }
  async handleRenegotiationAnswer(message, fromPeerId) {
    this.debug.log(`🔄 Handling renegotiation answer via mesh from ${fromPeerId.substring(0, 8)}...`);
    // Find the existing peer connection
    const peerConnection = this.peers.get(fromPeerId);
    if (!peerConnection) {
      this.debug.error(`No peer connection found for renegotiation answer from ${fromPeerId.substring(0, 8)}...`);
      return;
    }
    try {
      // Handle the renegotiation answer
      await peerConnection.handleAnswer(message.data);
      this.debug.log(`✅ Renegotiation answer processed from ${fromPeerId.substring(0, 8)}... - renegotiation complete`);
    } catch (error) {
      this.debug.error(`❌ Failed to handle renegotiation answer via mesh from ${fromPeerId.substring(0, 8)}...`, error);
    }
  }
  /**
   * Monitor and fix stuck connections that remain in "have-local-offer" state
   * This is called periodically to detect and fix connections that get stuck
   */
  monitorAndFixStuckConnections() {
    if (!this.mesh.connected) return;
    const stuckConnections = [];
    for (const [peerId, peerConnection] of this.peers) {
      if (peerConnection.connection?.signalingState === 'have-local-offer') {
        const connectionAge = Date.now() - peerConnection.connectionStartTime;
        // If connection has been stuck in "have-local-offer" for more than 10 seconds, fix it
        // This timeout balances between allowing time for signaling and detecting truly stuck connections
        if (connectionAge > 10000) {
          stuckConnections.push(peerId);
        }
      }
    }
    if (stuckConnections.length > 0) {
      this.debug.log(`🚨 STUCK MONITOR: Found ${stuckConnections.length} stuck connections - forcing recovery`);
      
      // Log a warning about local testing requirements
      if (typeof window !== 'undefined' && window.location?.hostname === 'localhost') {
        console.warn('⚠️ LOCAL TESTING: WebRTC connections on localhost require media permissions!');
        console.warn('   Go to the Media tab and click "Start Media" to grant permissions.');
        console.warn('   See docs/LOCAL_TESTING.md for details.');
      }
      for (const peerId of stuckConnections) {
        this.forceConnectionRecovery(peerId).catch(error => {
          this.debug.error(`Failed to recover stuck connection for ${peerId}:`, error);
        });
      }
    }
  }
  /**
   * Force recovery of a stuck connection by completely recreating it
   */
  async forceConnectionRecovery(peerId) {
    const peerConnection = this.getPeer(peerId);
    if (!peerConnection) {
      this.debug.error(`Cannot recover - peer ${peerId} not found`);
      return null;
    }
    this.debug.log(`🔄 FORCE RECOVERY: Completely recreating connection for ${peerId.substring(0, 8)}...`);
    try {
      // Preserve the current media stream
      const currentLocalStream = peerConnection.getLocalStream();
      // Close the stuck connection
      peerConnection.close();
      // Remove from peers map
      this.peers.delete(peerId);
      // Create a completely fresh connection
      const freshConnection = await this.connectToPeer(peerId, false, {
        localStream: currentLocalStream
      });
      if (freshConnection && currentLocalStream) {
        // Apply the media stream to the fresh connection
        await freshConnection.setLocalStream(currentLocalStream);
        this.debug.log(`✅ FORCE RECOVERY: Fresh connection created with media for ${peerId.substring(0, 8)}...`);
      }
      return freshConnection;
    } catch (error) {
      this.debug.error(`❌ FORCE RECOVERY: Failed to recreate connection for ${peerId.substring(0, 8)}...`, error);
      throw error;
    }
  }
  /**
   * Start monitoring for stuck connections
   */
  startStuckConnectionMonitoring() {
    // Check every 2 seconds for stuck connections - much more responsive
    setInterval(() => {
      this.monitorAndFixStuckConnections();
    }, 2000);
    this.debug.log('🔍 Started stuck connection monitoring');
  }
  /**
   * Forward a received stream to all other connected peers (except the sender)
   * This implements media forwarding through the mesh topology
   * @param {MediaStream} stream - The stream to forward
   * @param {string} sourcePeerId - The peer ID that sent the stream (don't forward back to them)
   * @private
   */
  async _forwardStreamToOtherPeers(stream, sourcePeerId) {
    if (!stream || !sourcePeerId) {
      this.debug.warn('Cannot forward stream - invalid parameters');
      return;
    }
    this.debug.log(`🔄 FORWARD STREAM: Forwarding stream from ${sourcePeerId.substring(0, 8)}... to other connected peers`);
    // Get the original source peer ID from the stream metadata
    const originalSourcePeerId = stream._peerPigeonSourcePeerId || sourcePeerId;
    // Count how many peers we're forwarding to
    let forwardCount = 0;
    // Forward to all connected peers except the source and the original sender
    for (const [peerId, connection] of this.peers) {
      // Skip the peer who sent us the stream
      if (peerId === sourcePeerId) {
        this.debug.log(`🔄 FORWARD STREAM: Skipping source peer ${peerId.substring(0, 8)}...`);
        continue;
      }
      // Skip the original peer who created the stream (to prevent loops)
      if (peerId === originalSourcePeerId) {
        this.debug.log(`🔄 FORWARD STREAM: Skipping original stream creator ${peerId.substring(0, 8)}...`);
        continue;
      }
      // Only forward to connected peers
      if (connection.getStatus() !== 'connected') {
        this.debug.log(`🔄 FORWARD STREAM: Skipping disconnected peer ${peerId.substring(0, 8)}...`);
        continue;
      }
      try {
        this.debug.log(`🔄 FORWARD STREAM: Setting forwarded stream for peer ${peerId.substring(0, 8)}...`);
        // CRITICAL: Clone the stream to avoid conflicts
        const forwardedStream = stream.clone();
        // Mark the forwarded stream with original source information
        Object.defineProperty(forwardedStream, '_peerPigeonSourcePeerId', {
          value: originalSourcePeerId,
          writable: false,
          enumerable: false,
          configurable: false
        });
        Object.defineProperty(forwardedStream, '_peerPigeonOrigin', {
          value: 'forwarded',
          writable: false,
          enumerable: false,
          configurable: false
        });
        // Set the cloned stream as the local stream for this connection
        await connection.setLocalStream(forwardedStream);
        forwardCount++;
        this.debug.log(`✅ FORWARD STREAM: Successfully forwarded stream to peer ${peerId.substring(0, 8)}...`);
      } catch (error) {
        this.debug.error(`❌ FORWARD STREAM: Failed to forward stream to peer ${peerId.substring(0, 8)}...`, error);
      }
    }
    this.debug.log(`🔄 FORWARD STREAM: Forwarded stream from ${sourcePeerId.substring(0, 8)}... to ${forwardCount} peer(s)`);
  }
}