From f638af4eb8242d6391f2029f24174f407d9bf665 Mon Sep 17 00:00:00 2001 From: Eric Dahl <132486009+edahl_UND@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:47:18 -0400 Subject: [PATCH] Fix UDP socket binding on reconnection (Fixes #25) - Modify closeUdpSockets() to recreate socket objects after shutdown - Add defensive validation in createUdpSockets() to ensure clean state - Add comprehensive logging for debugging connection flow - Add unit tests to verify socket reconnection behavior - Fix RTSP connection timeout by removing unnecessary socket readiness check - Fix logging output to use juce::Logger::writeToLog This resolves the 'Failed to create UDP sockets' error on retry attempts by ensuring socket objects are properly recreated after shutdown. --- Source/AirPlay/AirPlayManager.cpp | 70 ++++++---- Source/AirPlay/RaopClient.cpp | 168 +++++++++++++++------- Source/AirPlay/RaopClient.h | 20 +-- Source/Discovery/DeviceDiscoveryMac.mm | 2 + Source/PluginEditor.cpp | 44 +++--- Tests/RaopClientTests.cpp | 185 +++++++++++++++++-------- 6 files changed, 328 insertions(+), 161 deletions(-) diff --git a/Source/AirPlay/AirPlayManager.cpp b/Source/AirPlay/AirPlayManager.cpp index 192d856..a432c13 100644 --- a/Source/AirPlay/AirPlayManager.cpp +++ b/Source/AirPlay/AirPlayManager.cpp @@ -12,7 +12,7 @@ AirPlayManager::AirPlayManager() : Thread("AirPlayStream") { encoder = std::make_unique(); buffer = std::make_unique(2, 8192); - + #if JUCE_MAC streamer = std::make_unique(); #elif JUCE_WINDOWS @@ -37,44 +37,56 @@ void AirPlayManager::prepare(double sampleRate, int samplesPerBlock) void AirPlayManager::connectToDevice(const AirPlayDevice& device) { + juce::Logger::writeToLog("AirPlayManager: connectToDevice called for: " + device.getDeviceName() + " at " + device.getHostAddress() + ":" + juce::String(device.getPort())); const juce::ScopedLock sl(connectionLock); - + hasError = false; isReconnecting = false; - - if (streamer && streamer->connect(device)) + + if (streamer) { - connectedDevice = device; - lastError.clear(); - startThread(); - notifyStatusChange("Connected to " + device.getDeviceName()); - DBG("AirPlayManager: Connected to " + device.getDeviceName()); + juce::Logger::writeToLog("AirPlayManager: Streamer exists, attempting connection"); + if (streamer->connect(device)) + { + connectedDevice = device; + lastError.clear(); + startThread(); + notifyStatusChange("Connected to " + device.getDeviceName()); + juce::Logger::writeToLog("AirPlayManager: Connected to " + device.getDeviceName()); + } + else + { + lastError = streamer->getLastError(); + hasError = true; + notifyError("Connection failed: " + lastError); + juce::Logger::writeToLog("AirPlayManager: Connection failed - " + lastError); + } } else { - lastError = streamer ? streamer->getLastError() : "Streamer not available"; + lastError = "Streamer not available"; hasError = true; notifyError("Connection failed: " + lastError); - DBG("AirPlayManager: Connection failed - " + lastError); + juce::Logger::writeToLog("AirPlayManager: Streamer not available"); } } void AirPlayManager::disconnectFromDevice() { const juce::ScopedLock sl(connectionLock); - + signalThreadShouldExit(); notify(); stopThread(2000); - + if (streamer) streamer->disconnect(); - + connectedDevice = AirPlayDevice(); hasError = false; isReconnecting = false; notifyStatusChange("Disconnected"); - DBG("AirPlayManager: Disconnected"); + juce::Logger::writeToLog("AirPlayManager: Disconnected"); } bool AirPlayManager::isConnected() const @@ -104,19 +116,19 @@ juce::String AirPlayManager::getLastError() const juce::String AirPlayManager::getConnectionStatus() const { const juce::ScopedLock sl(connectionLock); - + if (!streamer) return "Not initialized"; - + if (isReconnecting) return "Reconnecting..."; - + if (hasError) return "Error: " + lastError; - + if (streamer->isConnected()) return "Connected to " + connectedDevice.getDeviceName(); - + return "Disconnected"; } @@ -126,7 +138,7 @@ void AirPlayManager::setAutoReconnect(bool enable) { // Platform-specific implementation would need this method // For now, just log it - DBG("AirPlayManager: Auto-reconnect " + juce::String(enable ? "enabled" : "disabled")); + juce::Logger::writeToLog("AirPlayManager: Auto-reconnect " + juce::String(enable ? "enabled" : "disabled")); } } @@ -150,10 +162,10 @@ void AirPlayManager::processAudioStream() { if (!isConnected() || !buffer || !encoder || !streamer) return; - + juce::AudioBuffer tempBuffer(2, currentSamplesPerBlock); int samplesRead = buffer->read(tempBuffer, currentSamplesPerBlock); - + if (samplesRead > 0) { if (!streamer->streamAudio(tempBuffer, samplesRead)) @@ -170,16 +182,16 @@ void AirPlayManager::processAudioStream() void AirPlayManager::monitorConnection() { juce::int64 now = juce::Time::currentTimeMillis(); - + // Monitor every 5 seconds if (now - lastMonitorTime < 5000) return; - + lastMonitorTime = now; - + if (!streamer) return; - + // Check if we're still connected if (streamer->isConnected()) { @@ -190,7 +202,7 @@ void AirPlayManager::monitorConnection() hasError = false; isReconnecting = false; notifyStatusChange("Connection recovered"); - DBG("AirPlayManager: Connection recovered"); + juce::Logger::writeToLog("AirPlayManager: Connection recovered"); } } else if (!hasError) @@ -200,7 +212,7 @@ void AirPlayManager::monitorConnection() lastError = "Connection lost to " + connectedDevice.getDeviceName(); hasError = true; notifyError(lastError); - DBG("AirPlayManager: " + lastError); + juce::Logger::writeToLog("AirPlayManager: " + lastError); } } diff --git a/Source/AirPlay/RaopClient.cpp b/Source/AirPlay/RaopClient.cpp index 04c32a0..7112db6 100644 --- a/Source/AirPlay/RaopClient.cpp +++ b/Source/AirPlay/RaopClient.cpp @@ -20,6 +20,8 @@ RaopClient::~RaopClient() bool RaopClient::connect(const AirPlayDevice& device) { + juce::Logger::writeToLog("[RaopClient] connect() called for device: " + device.getDeviceName() + " at " + device.getHostAddress() + ":" + juce::String(device.getPort())); + if (connected) disconnect(); @@ -27,9 +29,10 @@ bool RaopClient::connect(const AirPlayDevice& device) lastConnectionAttemptTime = juce::Time::currentTimeMillis(); consecutiveFailures = 0; reconnectAttempts = 0; - + currentDevice = device; cseq = 1; // Reset sequence number + juce::Logger::writeToLog("[RaopClient] Connection state set to Connecting"); // Initialize authentication if enabled if (useAuthentication) @@ -55,25 +58,20 @@ bool RaopClient::connect(const AirPlayDevice& device) } // Try to connect with timeout + juce::Logger::writeToLog("RaopClient: Attempting TCP connection to " + device.getHostAddress() + ":" + juce::String(device.getPort())); if (!socket->connect(device.getHostAddress(), device.getPort(), 10000)) { lastError = "Failed to connect to " + device.getHostAddress(); logError(lastError); + juce::Logger::writeToLog("RaopClient: TCP connection failed - device may be unreachable or not accepting connections"); setConnectionState(ConnectionState::TimedOut); closeUdpSockets(); return false; } - - // Verify socket is ready - if (!waitForSocketReady(socket.get(), 5000)) - { - lastError = "Connection timeout - device not responding"; - logError(lastError); - setConnectionState(ConnectionState::TimedOut); - socket->close(); - closeUdpSockets(); - return false; - } + juce::Logger::writeToLog("RaopClient: TCP connection established successfully"); + + // Skip socket readiness check - AirPlay devices expect immediate RTSP handshake + juce::Logger::writeToLog("RaopClient: TCP connection established, proceeding with RTSP handshake"); // Initialize timing startTime = juce::Time::getCurrentTime(); @@ -81,27 +79,35 @@ bool RaopClient::connect(const AirPlayDevice& device) rtpTimestamp = 0; // Perform RTSP handshake with authentication + juce::Logger::writeToLog("RaopClient: Starting RTSP handshake"); if (useAuthentication) { + juce::Logger::writeToLog("RaopClient: Sending OPTIONS request"); if (!sendOptions()) { + juce::Logger::writeToLog("RaopClient: OPTIONS request failed"); disconnect(); return false; } + juce::Logger::writeToLog("RaopClient: Sending ANNOUNCE request"); if (!sendAnnounce()) { + juce::Logger::writeToLog("RaopClient: ANNOUNCE request failed"); disconnect(); return false; } } + juce::Logger::writeToLog("RaopClient: Sending SETUP request"); if (!sendSetup()) { + juce::Logger::writeToLog("RaopClient: SETUP request failed"); disconnect(); return false; } + juce::Logger::writeToLog("RaopClient: Sending RECORD request"); if (!sendRecord()) { disconnect(); @@ -113,14 +119,14 @@ bool RaopClient::connect(const AirPlayDevice& device) lastSuccessfulSendTime = juce::Time::currentTimeMillis(); consecutiveFailures = 0; reconnectAttempts = 0; - DBG("RaopClient: Successfully connected to " + device.getDeviceName()); + juce::Logger::writeToLog("RaopClient: Successfully connected to " + device.getDeviceName()); return true; } void RaopClient::disconnect() { const juce::ScopedLock sl(stateLock); - + if (connected) { sendTeardown(); @@ -128,7 +134,7 @@ void RaopClient::disconnect() closeUdpSockets(); connected = false; setConnectionState(ConnectionState::Disconnected); - DBG("RaopClient: Disconnected from " + currentDevice.getDeviceName()); + juce::Logger::writeToLog("RaopClient: Disconnected from " + currentDevice.getDeviceName()); } } @@ -182,12 +188,12 @@ bool RaopClient::sendAudio(const juce::MemoryBlock& audioData, int /*sampleRate* { lastError = "Failed to send RTP packet"; consecutiveFailures++; - + if (consecutiveFailures > maxConsecutiveFailures) { logError("Network send failure detected, marking connection as error"); setConnectionState(ConnectionState::Error); - + if (autoReconnectEnabled) { attemptReconnect(); @@ -231,12 +237,15 @@ bool RaopClient::sendRtspRequest(const juce::String& method, const juce::String& if (body.isNotEmpty()) request += body; + juce::Logger::writeToLog("RaopClient: Sending RTSP request: " + request); int sent = socket->write(request.toRawUTF8(), request.length()); if (sent != request.length()) { lastError = "Failed to send RTSP request"; + juce::Logger::writeToLog("RaopClient: Failed to send RTSP request - sent " + juce::String(sent) + " of " + juce::String(request.length()) + " bytes"); return false; } + juce::Logger::writeToLog("RaopClient: Successfully sent " + juce::String(sent) + " bytes"); // Read response if requested if (response != nullptr) @@ -246,23 +255,29 @@ bool RaopClient::sendRtspRequest(const juce::String& method, const juce::String& if (bytesRead <= 0) { lastError = "Failed to read RTSP response"; + juce::Logger::writeToLog("RaopClient: No response received from device"); return false; } buffer[bytesRead] = '\0'; juce::String responseText(buffer); + juce::Logger::writeToLog("RaopClient: Received RTSP response: " + responseText); if (!parseRtspResponse(responseText, *response)) { lastError = "Failed to parse RTSP response"; + juce::Logger::writeToLog("RaopClient: Failed to parse RTSP response: " + responseText); return false; } if (!response->isSuccess()) { lastError = "RTSP request failed: " + juce::String(response->statusCode) + " " + response->statusMessage; + juce::Logger::writeToLog("RaopClient: RTSP request failed with status " + juce::String(response->statusCode) + ": " + response->statusMessage); return false; } + + juce::Logger::writeToLog("RaopClient: RTSP request successful with status " + juce::String(response->statusCode)); } return true; @@ -278,7 +293,7 @@ bool RaopClient::parseRtspResponse(const juce::String& responseText, RtspRespons juce::String statusLine = lines[0].trim(); juce::StringArray statusParts; statusParts.addTokens(statusLine, " ", ""); - + if (statusParts.size() < 2) return false; @@ -322,7 +337,7 @@ bool RaopClient::parseTransportHeader(const juce::String& transport, int& audioP { // Example: RTP/AVP/UDP;unicast;server_port=6000-6001;control_port=6001;timing_port=6002 // or: RTP/AVP/UDP;unicast;server_port=6000-6001 - + audioPort = 0; controlPort = 0; timingPort = 0; @@ -333,28 +348,28 @@ bool RaopClient::parseTransportHeader(const juce::String& transport, int& audioP { juce::String serverPortsStr = transport.substring(serverPortIndex + 12); int dashIndex = serverPortsStr.indexOf("-"); - + if (dashIndex > 0) { // Extract audio port audioPort = serverPortsStr.substring(0, dashIndex).getIntValue(); - + // Extract control port juce::String controlPortStr = serverPortsStr.substring(dashIndex + 1); int semicolonIndex = controlPortStr.indexOf(";"); int spaceIndex = controlPortStr.indexOfChar(' '); int endIndex = -1; - + if (semicolonIndex >= 0 && spaceIndex >= 0) endIndex = juce::jmin(semicolonIndex, spaceIndex); else if (semicolonIndex >= 0) endIndex = semicolonIndex; else if (spaceIndex >= 0) endIndex = spaceIndex; - + if (endIndex > 0) controlPortStr = controlPortStr.substring(0, endIndex); - + controlPort = controlPortStr.trim().getIntValue(); } } @@ -366,12 +381,12 @@ bool RaopClient::parseTransportHeader(const juce::String& transport, int& audioP juce::String timingPortStr = transport.substring(timingPortIndex + 12); int semicolonIndex = timingPortStr.indexOf(";"); int spaceIndex = timingPortStr.indexOfChar(' '); - + if (semicolonIndex >= 0) timingPortStr = timingPortStr.substring(0, semicolonIndex); else if (spaceIndex >= 0) timingPortStr = timingPortStr.substring(0, spaceIndex); - + timingPort = timingPortStr.trim().getIntValue(); } // Some servers might specify timing port as the third port in server_port range @@ -433,7 +448,7 @@ bool RaopClient::sendAnnounce() sdp += "s=FreeCaster Audio Stream\r\n"; sdp += "c=IN IP4 " + currentDevice.getHostAddress() + "\r\n"; sdp += "t=0 0\r\n"; - + // Media description sdp += "m=audio 0 RTP/AVP 96\r\n"; sdp += "a=rtpmap:96 AppleLossless\r\n"; @@ -514,7 +529,7 @@ bool RaopClient::sendRecord() headers.set("CSeq", juce::String(cseq++)); headers.set("Range", "npt=0-"); headers.set("RTP-Info", "seq=0;rtptime=0"); - + if (session.isNotEmpty()) headers.set("Session", session); @@ -525,7 +540,7 @@ bool RaopClient::sendTeardown() { juce::StringPairArray headers; headers.set("CSeq", juce::String(cseq++)); - + if (session.isNotEmpty()) headers.set("Session", session); @@ -534,35 +549,90 @@ bool RaopClient::sendTeardown() bool RaopClient::createUdpSockets() { + juce::Logger::writeToLog("RaopClient: Creating UDP sockets - audio:" + juce::String(clientAudioPort) + + " control:" + juce::String(clientControlPort) + " timing:" + juce::String(clientTimingPort)); + + // Validate socket objects exist + if (!audioSocket) + { + lastError = "Audio socket is null"; + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); + return false; + } + if (!controlSocket) + { + lastError = "Control socket is null"; + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); + return false; + } + if (!timingSocket) + { + lastError = "Timing socket is null"; + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); + return false; + } + // Create audio socket + juce::Logger::writeToLog("RaopClient: Binding audio socket to port " + juce::String(clientAudioPort) + + " (socket at " + juce::String::toHexString(reinterpret_cast(audioSocket.get())) + ")"); if (!audioSocket->bindToPort(clientAudioPort)) { lastError = "Failed to bind audio socket to port " + juce::String(clientAudioPort); + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); return false; } + juce::Logger::writeToLog("RaopClient: Successfully bound audio socket to port " + juce::String(clientAudioPort)); // Create control socket + juce::Logger::writeToLog("RaopClient: Binding control socket to port " + juce::String(clientControlPort) + + " (socket at " + juce::String::toHexString(reinterpret_cast(controlSocket.get())) + ")"); if (!controlSocket->bindToPort(clientControlPort)) { lastError = "Failed to bind control socket to port " + juce::String(clientControlPort); + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); return false; } + juce::Logger::writeToLog("RaopClient: Successfully bound control socket to port " + juce::String(clientControlPort)); // Create timing socket + juce::Logger::writeToLog("RaopClient: Binding timing socket to port " + juce::String(clientTimingPort) + + " (socket at " + juce::String::toHexString(reinterpret_cast(timingSocket.get())) + ")"); if (!timingSocket->bindToPort(clientTimingPort)) { lastError = "Failed to bind timing socket to port " + juce::String(clientTimingPort); + juce::Logger::writeToLog("RaopClient ERROR: " + lastError); return false; } + juce::Logger::writeToLog("RaopClient: Successfully bound timing socket to port " + juce::String(clientTimingPort)); + juce::Logger::writeToLog("RaopClient: All UDP sockets created successfully"); return true; } void RaopClient::closeUdpSockets() { - if (audioSocket) audioSocket->shutdown(); - if (controlSocket) controlSocket->shutdown(); - if (timingSocket) timingSocket->shutdown(); + // Shutdown and recreate sockets to allow rebinding + if (audioSocket) + { + juce::Logger::writeToLog("RaopClient: Shutting down audio socket (port " + juce::String(clientAudioPort) + ")"); + audioSocket->shutdown(); + audioSocket = std::make_unique(); + juce::Logger::writeToLog("RaopClient: Recreated audio socket at " + juce::String::toHexString(reinterpret_cast(audioSocket.get()))); + } + if (controlSocket) + { + juce::Logger::writeToLog("RaopClient: Shutting down control socket (port " + juce::String(clientControlPort) + ")"); + controlSocket->shutdown(); + controlSocket = std::make_unique(); + juce::Logger::writeToLog("RaopClient: Recreated control socket at " + juce::String::toHexString(reinterpret_cast(controlSocket.get()))); + } + if (timingSocket) + { + juce::Logger::writeToLog("RaopClient: Shutting down timing socket (port " + juce::String(clientTimingPort) + ")"); + timingSocket->shutdown(); + timingSocket = std::make_unique(); + juce::Logger::writeToLog("RaopClient: Recreated timing socket at " + juce::String::toHexString(reinterpret_cast(timingSocket.get()))); + } } bool RaopClient::sendRtpPacket(const void* data, size_t size) @@ -600,7 +670,7 @@ void RaopClient::setConnectionState(ConnectionState newState) if (connectionState != newState) { connectionState = newState; - DBG("RaopClient: Connection state changed to " + getConnectionStateString()); + juce::Logger::writeToLog("RaopClient: Connection state changed to " + getConnectionStateString()); } } @@ -630,21 +700,21 @@ bool RaopClient::attemptReconnect() } return false; } - + // Exponential backoff: 1s, 2s, 4s, 8s, 16s int backoffMs = (1 << reconnectAttempts) * 1000; juce::int64 timeSinceLastAttempt = juce::Time::currentTimeMillis() - lastConnectionAttemptTime; - + if (timeSinceLastAttempt < backoffMs) { return false; // Too soon to retry } - + reconnectAttempts++; setConnectionState(ConnectionState::Reconnecting); - logError("Attempting reconnection (" + juce::String(reconnectAttempts) + "/" + + logError("Attempting reconnection (" + juce::String(reconnectAttempts) + "/" + juce::String(maxReconnectAttempts) + ")"); - + // Try to reconnect if (connect(currentDevice)) { @@ -652,7 +722,7 @@ bool RaopClient::attemptReconnect() reconnectAttempts = 0; return true; } - + return false; } @@ -661,42 +731,42 @@ bool RaopClient::checkConnection() { if (!connected) return false; - + // Check if connection has been idle too long (no successful sends) juce::int64 timeSinceLastSend = juce::Time::currentTimeMillis() - lastSuccessfulSendTime; if (timeSinceLastSend > 30000) // 30 seconds { logError("Connection appears stale (no activity for 30s)"); setConnectionState(ConnectionState::Error); - + if (autoReconnectEnabled) { return attemptReconnect(); } return false; } - + // Check socket status if (!socket || !socket->isConnected()) { logError("Socket disconnected"); connected = false; setConnectionState(ConnectionState::Error); - + if (autoReconnectEnabled) { return attemptReconnect(); } return false; } - + return true; } // Error logging void RaopClient::logError(const juce::String& error) { - DBG("RaopClient ERROR: " + error); + juce::Logger::writeToLog("RaopClient ERROR: " + error); juce::Logger::writeToLog("[RaopClient] " + error); } @@ -705,16 +775,16 @@ bool RaopClient::waitForSocketReady(juce::StreamingSocket* sock, int timeoutMs) { if (!sock || !sock->isConnected()) return false; - + juce::int64 startTime = juce::Time::currentTimeMillis(); - + while (juce::Time::currentTimeMillis() - startTime < timeoutMs) { if (sock->waitUntilReady(true, 100) == 1) return true; - + juce::Thread::sleep(50); } - + return false; } diff --git a/Source/AirPlay/RaopClient.h b/Source/AirPlay/RaopClient.h index 47d579b..270c92f 100644 --- a/Source/AirPlay/RaopClient.h +++ b/Source/AirPlay/RaopClient.h @@ -29,11 +29,11 @@ class RaopClient bool sendAudio(const juce::MemoryBlock& audioData, int sampleRate, int channels); juce::String getLastError() const { return lastError; } - + // Auto-reconnect settings void setAutoReconnect(bool enable) { autoReconnectEnabled = enable; } bool isAutoReconnectEnabled() const { return autoReconnectEnabled; } - + // Connection health monitoring bool checkConnection(); juce::int64 getLastSuccessfulSendTime() const { return lastSuccessfulSendTime; } @@ -51,20 +51,24 @@ class RaopClient juce::String statusMessage; juce::StringPairArray headers; juce::String body; - + bool isSuccess() const { return statusCode >= 200 && statusCode < 300; } }; - + bool parseRtspResponse(const juce::String& responseText, RtspResponse& response); bool parseTransportHeader(const juce::String& transport, int& audioPort, int& controlPort, int& timingPort); + // Socket management for testing + bool createUdpSockets(); + void closeUdpSockets(); + private: // Connection management void setConnectionState(ConnectionState newState); bool attemptReconnect(); void logError(const juce::String& error); bool waitForSocketReady(juce::StreamingSocket* sock, int timeoutMs); - + // RTP header structure (12 bytes) struct RTPHeader { @@ -90,8 +94,6 @@ class RaopClient bool sendRecord(); bool sendTeardown(); - bool createUdpSockets(); - void closeUdpSockets(); bool sendRtpPacket(const void* data, size_t size); NTPTimestamp getCurrentNtpTimestamp() const; @@ -104,7 +106,7 @@ class RaopClient bool connected = false; ConnectionState connectionState = ConnectionState::Disconnected; juce::String lastError; - + // Reliability and monitoring bool autoReconnectEnabled = true; int reconnectAttempts = 0; @@ -113,7 +115,7 @@ class RaopClient juce::int64 lastConnectionAttemptTime = 0; int consecutiveFailures = 0; static constexpr int maxConsecutiveFailures = 10; - + // Thread safety juce::CriticalSection stateLock; diff --git a/Source/Discovery/DeviceDiscoveryMac.mm b/Source/Discovery/DeviceDiscoveryMac.mm index 3f022a9..e552354 100644 --- a/Source/Discovery/DeviceDiscoveryMac.mm +++ b/Source/Discovery/DeviceDiscoveryMac.mm @@ -35,6 +35,7 @@ - (id)initWithOwner:(DeviceDiscovery*)discoveryOwner - (void)start { + NSLog(@"AirPlayServiceBrowser: Starting discovery for _raop._tcp."); [browser searchForServicesOfType:@"_raop._tcp." inDomain:@"local."]; } @@ -47,6 +48,7 @@ - (void)netServiceBrowser:(NSNetServiceBrowser*)browser didFindService:(NSNetService*)service moreComing:(BOOL)moreComing { + NSLog(@"AirPlayServiceBrowser: Found service: %@", [service name]); [services addObject:service]; [service setDelegate:self]; [service resolveWithTimeout:5.0]; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 707cb9c..73aac9c 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -41,16 +41,16 @@ AirPlayPluginEditor::AirPlayPluginEditor(AirPlayPluginProcessor& p) bufferHealthLabel.setFont(juce::Font(11.0f)); bufferHealthLabel.setColour(juce::Label::textColourId, juce::Colours::lightgrey); addAndMakeVisible(bufferHealthLabel); - + // Set up level meters addAndMakeVisible(inputMeter); addAndMakeVisible(outputMeter); - + inputMeterLabel.setText("Input", juce::dontSendNotification); inputMeterLabel.setJustificationType(juce::Justification::centred); inputMeterLabel.setFont(juce::Font(11.0f)); addAndMakeVisible(inputMeterLabel); - + outputMeterLabel.setText("Output", juce::dontSendNotification); outputMeterLabel.setJustificationType(juce::Justification::centred); outputMeterLabel.setFont(juce::Font(11.0f)); @@ -61,7 +61,7 @@ AirPlayPluginEditor::AirPlayPluginEditor(AirPlayPluginProcessor& p) { showError(error); }; - + audioProcessor.getAirPlayManager().onStatusChange = [this](const juce::String& status) { showStatus(status); @@ -86,7 +86,7 @@ void AirPlayPluginEditor::paint(juce::Graphics& g) void AirPlayPluginEditor::resized() { auto area = getLocalBounds().reduced(10); - + // Reserve space for meters on the right auto metersArea = area.removeFromRight(100); area.removeFromRight(10); // Gap @@ -96,10 +96,10 @@ void AirPlayPluginEditor::resized() statusLabel.setBounds(area.removeFromTop(25)); area.removeFromTop(3); - + errorLabel.setBounds(area.removeFromTop(20)); area.removeFromTop(3); - + bufferHealthLabel.setBounds(area.removeFromTop(20)); area.removeFromTop(10); @@ -110,17 +110,17 @@ void AirPlayPluginEditor::resized() connectButton.setBounds(buttonArea.removeFromLeft(165)); buttonArea.removeFromLeft(10); disconnectButton.setBounds(buttonArea); - + // Layout meters vertically on the right side metersArea.removeFromTop(40); // Align with title - + auto inputMeterArea = metersArea.removeFromLeft(40); metersArea.removeFromLeft(10); auto outputMeterArea = metersArea.removeFromLeft(40); - + inputMeterLabel.setBounds(inputMeterArea.removeFromTop(20)); inputMeter.setBounds(inputMeterArea.removeFromTop(340)); - + outputMeterLabel.setBounds(outputMeterArea.removeFromTop(20)); outputMeter.setBounds(outputMeterArea.removeFromTop(340)); } @@ -129,7 +129,7 @@ void AirPlayPluginEditor::timerCallback() { updateStatusDisplay(); updateBufferHealth(); - + // Update level meters inputMeter.setLevel(audioProcessor.getInputLevel()); outputMeter.setLevel(audioProcessor.getOutputLevel()); @@ -138,14 +138,14 @@ void AirPlayPluginEditor::timerCallback() void AirPlayPluginEditor::updateStatusDisplay() { auto& manager = audioProcessor.getAirPlayManager(); - + if (manager.isConnected()) { statusLabel.setText(manager.getConnectionStatus(), juce::dontSendNotification); statusLabel.setColour(juce::Label::textColourId, juce::Colours::lightgreen); connectButton.setEnabled(false); disconnectButton.setEnabled(true); - + // Clear error if connected successfully juce::String lastError = manager.getLastError(); if (lastError.isEmpty()) @@ -157,7 +157,7 @@ void AirPlayPluginEditor::updateStatusDisplay() statusLabel.setColour(juce::Label::textColourId, juce::Colours::white); connectButton.setEnabled(true); disconnectButton.setEnabled(false); - + // Show error if present juce::String lastError = manager.getLastError(); if (lastError.isNotEmpty()) @@ -186,7 +186,7 @@ void AirPlayPluginEditor::updateBufferHealth() void AirPlayPluginEditor::showError(const juce::String& error) { errorLabel.setText("⚠ " + error, juce::dontSendNotification); - DBG("GUI Error: " + error); + juce::Logger::writeToLog("GUI Error: " + error); } void AirPlayPluginEditor::showStatus(const juce::String& status) @@ -205,11 +205,12 @@ void AirPlayPluginEditor::showStatus(const juce::String& status) { statusLabel.setColour(juce::Label::textColourId, juce::Colours::white); } - DBG("GUI Status: " + status); + juce::Logger::writeToLog("GUI Status: " + status); } void AirPlayPluginEditor::deviceFound(const AirPlayDevice& device) { + juce::Logger::writeToLog("AirPlayPluginEditor: Device found - " + device.getDeviceName() + " at " + device.getHostAddress() + ":" + juce::String(device.getPort())); updateDeviceList(); } @@ -233,17 +234,24 @@ void AirPlayPluginEditor::updateDeviceList() void AirPlayPluginEditor::connectButtonClicked() { + juce::Logger::writeToLog("AirPlayPluginEditor: Connect button clicked"); int selectedRow = deviceListBox.getSelectedRow(); + juce::Logger::writeToLog("AirPlayPluginEditor: Selected row: " + juce::String(selectedRow) + ", devices.size(): " + juce::String(devices.size())); if (selectedRow >= 0 && selectedRow < devices.size()) { + juce::Logger::writeToLog("AirPlayPluginEditor: Attempting to connect to device: " + devices[selectedRow].getDeviceName()); audioProcessor.getAirPlayManager().connectToDevice(devices[selectedRow]); } + else + { + juce::Logger::writeToLog("AirPlayPluginEditor: No device selected or invalid selection"); + } } void AirPlayPluginEditor::disconnectButtonClicked() { audioProcessor.getAirPlayManager().disconnectFromDevice(); - + // Reset meters when disconnected outputMeter.reset(); } diff --git a/Tests/RaopClientTests.cpp b/Tests/RaopClientTests.cpp index 29cef37..53bb348 100644 --- a/Tests/RaopClientTests.cpp +++ b/Tests/RaopClientTests.cpp @@ -5,186 +5,187 @@ class RaopClientTests : public juce::UnitTest { public: RaopClientTests() : juce::UnitTest("RaopClient") {} - + void runTest() override { testRtspResponseParsing(); testTransportHeaderParsing(); testRtpHeaderConstruction(); + testSocketReconnection(); } - + private: void testRtspResponseParsing() { beginTest("Parse valid RTSP 200 OK response"); { - juce::String validResponse = + juce::String validResponse = "RTSP/1.0 200 OK\r\n" "CSeq: 1\r\n" "Session: 12345\r\n" "Transport: RTP/AVP/UDP;server_port=6000-6001\r\n" "\r\n"; - + RaopClient client; RaopClient::RtspResponse response; - + // Use reflection to call private method (we'll create a test helper) bool success = parseRtspResponseHelper(client, validResponse, response); - + expect(success, "Should successfully parse valid response"); expectEquals(response.statusCode, 200, "Status code should be 200"); expect(response.isSuccess(), "Response should be marked as success"); expect(response.headers["Session"] == "12345", "Session header should match"); expect(response.headers["Transport"].isNotEmpty(), "Transport header should exist"); } - + beginTest("Parse RTSP error response (404)"); { - juce::String errorResponse = + juce::String errorResponse = "RTSP/1.0 404 Not Found\r\n" "CSeq: 2\r\n" "\r\n"; - + RaopClient client; RaopClient::RtspResponse response; bool success = parseRtspResponseHelper(client, errorResponse, response); - + expect(success, "Should successfully parse error response"); expectEquals(response.statusCode, 404, "Status code should be 404"); expect(!response.isSuccess(), "Response should not be marked as success"); } - + beginTest("Parse RTSP response with body content"); { - juce::String responseWithBody = + juce::String responseWithBody = "RTSP/1.0 200 OK\r\n" "CSeq: 3\r\n" "Content-Type: application/sdp\r\n" "Content-Length: 10\r\n" "\r\n" "SDP body\n"; - + RaopClient client; RaopClient::RtspResponse response; bool success = parseRtspResponseHelper(client, responseWithBody, response); - + expect(success, "Should successfully parse response with body"); expect(response.body.trim() == "SDP body", "Body should be parsed correctly"); } - + beginTest("Parse malformed RTSP response"); { juce::String malformedResponse = "Invalid response\r\n"; - + RaopClient client; RaopClient::RtspResponse response; bool success = parseRtspResponseHelper(client, malformedResponse, response); - + // Parser is lenient and may succeed even with malformed input // Just verify it doesn't crash expect(true, "Parser should handle malformed input gracefully"); } - + beginTest("Parse RTSP response with multi-line headers"); { - juce::String multiLineResponse = + juce::String multiLineResponse = "RTSP/1.0 200 OK\r\n" "CSeq: 4\r\n" "Session: ABCDEF123456\r\n" "Transport: RTP/AVP/UDP;unicast;server_port=6000-6001;timing_port=6002\r\n" "Server: AirTunes/220.68\r\n" "\r\n"; - + RaopClient client; RaopClient::RtspResponse response; bool success = parseRtspResponseHelper(client, multiLineResponse, response); - + expect(success, "Should successfully parse multi-line response"); expectEquals(response.statusCode, 200, "Status code should be 200"); expect(response.headers["Session"] == "ABCDEF123456", "Session should match"); expect(response.headers["Server"].isNotEmpty(), "Server header should exist"); } } - + void testTransportHeaderParsing() { beginTest("Parse standard transport header"); { juce::String transport = "RTP/AVP/UDP;server_port=6000-6001;timing_port=6002"; int audioPort = 0, controlPort = 0, timingPort = 0; - + RaopClient client; bool success = parseTransportHeaderHelper(client, transport, audioPort, controlPort, timingPort); - + expect(success, "Should successfully parse transport header"); expectEquals(audioPort, 6000, "Audio port should be 6000"); expectEquals(controlPort, 6001, "Control port should be 6001"); expectEquals(timingPort, 6002, "Timing port should be 6002"); } - + beginTest("Parse transport header without explicit timing port"); { juce::String transport = "RTP/AVP/UDP;server_port=6000-6001"; int audioPort = 0, controlPort = 0, timingPort = 0; - + RaopClient client; bool success = parseTransportHeaderHelper(client, transport, audioPort, controlPort, timingPort); - + expect(success, "Should successfully parse transport without timing port"); expectEquals(audioPort, 6000, "Audio port should be 6000"); expectEquals(controlPort, 6001, "Control port should be 6001"); expectEquals(timingPort, 6002, "Timing port should default to control+1"); } - + beginTest("Parse transport header with additional parameters"); { juce::String transport = "RTP/AVP/UDP;unicast;interleaved=0-1;server_port=7000-7001;timing_port=7002;mode=record"; int audioPort = 0, controlPort = 0, timingPort = 0; - + RaopClient client; bool success = parseTransportHeaderHelper(client, transport, audioPort, controlPort, timingPort); - + expect(success, "Should successfully parse complex transport header"); expectEquals(audioPort, 7000, "Audio port should be 7000"); expectEquals(controlPort, 7001, "Control port should be 7001"); expectEquals(timingPort, 7002, "Timing port should be 7002"); } - + beginTest("Parse malformed transport header"); { juce::String transport = "Invalid transport"; int audioPort = 0, controlPort = 0, timingPort = 0; - + RaopClient client; bool success = parseTransportHeaderHelper(client, transport, audioPort, controlPort, timingPort); - + expect(!success, "Should fail to parse malformed transport header"); } - + beginTest("Parse transport header with various port formats"); { juce::String transport = "RTP/AVP/UDP;server_port=5000-5001;timing_port=5002 "; int audioPort = 0, controlPort = 0, timingPort = 0; - + RaopClient client; bool success = parseTransportHeaderHelper(client, transport, audioPort, controlPort, timingPort); - + expect(success, "Should handle trailing whitespace"); expectEquals(audioPort, 5000, "Audio port should be 5000"); } } - + void testRtpHeaderConstruction() { beginTest("RTP header version flags"); { // Create a RaopClient and test RTP header construction through sendAudio RaopClient client; - + // Create test audio data juce::MemoryBlock audioData(512); audioData.fillWith(0); - + // We can't directly test sendAudio without a connection, but we can verify // the RTPHeader struct layout and expected values struct RTPHeader @@ -195,87 +196,159 @@ class RaopClientTests : public juce::UnitTest uint32_t timestamp; uint32_t ssrc; }; - + RTPHeader header; header.version_flags = 0x80; // Version 2 header.payload_type = 0x60; // Payload type 96 header.sequence_number = 0; header.timestamp = 0; header.ssrc = 0x12345678; - + expect((header.version_flags & 0xC0) == 0x80, "Version should be 2"); expect((header.version_flags & 0x20) == 0, "Padding should be off"); expect((header.version_flags & 0x10) == 0, "Extension should be off"); } - + beginTest("RTP payload type with marker bit"); { uint8_t payloadType = 0x60; // Payload type 96 uint8_t markerBit = 0x80; // Marker bit - + uint8_t payloadWithMarker = payloadType | markerBit; expect((payloadWithMarker & 0x80) != 0, "Marker bit should be set"); expect((payloadWithMarker & 0x7F) == 0x60, "Payload type should be preserved"); - + uint8_t payloadWithoutMarker = payloadType; expect((payloadWithoutMarker & 0x80) == 0, "Marker bit should not be set"); } - + beginTest("RTP sequence number increments"); { uint16_t seq = 0; expect(seq == 0, "Initial sequence should be 0"); - + seq++; expect(seq == 1, "Sequence should increment"); - + // Test rollover seq = 0xFFFF; seq++; expect(seq == 0, "Sequence should rollover after 65535"); } - + beginTest("RTP timestamp calculations"); { uint32_t timestamp = 0; int samplesPerPacket = 256; - + timestamp += samplesPerPacket; expect(timestamp == 256, "Timestamp should increment by samples"); - + timestamp += samplesPerPacket; expect(timestamp == 512, "Timestamp should continue incrementing"); } - + beginTest("RTP SSRC identifier"); { uint32_t ssrc = 0x12345678; expect(ssrc != 0, "SSRC should be non-zero"); - + // In actual implementation, SSRC should be random // but we just verify it's used correctly uint32_t swapped = juce::ByteOrder::swapIfBigEndian(ssrc); expect(swapped != 0, "Swapped SSRC should still be non-zero"); } } - + + void testSocketReconnection() + { + beginTest("Socket cleanup and recreation"); + { + RaopClient client; + + // Test that we can create sockets initially + expect(client.createUdpSockets(), "Should be able to create UDP sockets initially"); + + // Test that we can close and recreate sockets + client.closeUdpSockets(); + expect(client.createUdpSockets(), "Should be able to recreate UDP sockets after close"); + + // Test multiple close/recreate cycles + for (int i = 0; i < 3; ++i) + { + client.closeUdpSockets(); + expect(client.createUdpSockets(), "Should be able to recreate sockets in cycle " + juce::String(i + 1)); + } + } + + beginTest("Socket object recreation verification"); + { + RaopClient client; + + // Test that we can create sockets initially + expect(client.createUdpSockets(), "Initial socket creation should succeed"); + + // Close sockets (this should recreate them internally) + client.closeUdpSockets(); + + // Test that we can recreate sockets after close + expect(client.createUdpSockets(), "Socket recreation after close should succeed"); + + // Test multiple cycles to ensure recreation works consistently + for (int i = 0; i < 3; ++i) + { + client.closeUdpSockets(); + expect(client.createUdpSockets(), "Multiple socket recreation cycles should work (cycle " + juce::String(i + 1) + ")"); + } + } + + beginTest("Port binding after socket recreation"); + { + RaopClient client; + + // Create sockets first time + expect(client.createUdpSockets(), "First socket creation should succeed"); + + // Close sockets + client.closeUdpSockets(); + + // Immediately try to recreate - this should work now + expect(client.createUdpSockets(), "Socket recreation should succeed immediately after close"); + + // Test that we can do this multiple times + for (int i = 0; i < 5; ++i) + { + client.closeUdpSockets(); + expect(client.createUdpSockets(), "Multiple socket recreation cycles should work (attempt " + juce::String(i + 1) + ")"); + } + } + } + // Helper methods to access private methods via friend class or public testing interface bool parseRtspResponseHelper(RaopClient& client, const juce::String& responseText, RaopClient::RtspResponse& response) { // Since parseRtspResponse is private, we'll use a workaround // In a real implementation, you might make test classes friends or add test-only public wrappers // For now, we'll test through the public interface indirectly - + // Direct access - this requires making the method public or adding friend class // For this demonstration, we'll assume the method is accessible return client.parseRtspResponse(responseText, response); } - - bool parseTransportHeaderHelper(RaopClient& client, const juce::String& transport, + + bool parseTransportHeaderHelper(RaopClient& client, const juce::String& transport, int& audioPort, int& controlPort, int& timingPort) { return client.parseTransportHeader(transport, audioPort, controlPort, timingPort); } + + void* getSocketAddressHelper(RaopClient& client, const juce::String& socketType) + { + // Since we can't access private members directly, we'll test the functionality + // by verifying that socket operations work correctly rather than checking addresses + // This is actually a better test as it verifies the behavior rather than implementation + return nullptr; // Not used in the current test approach + } }; static RaopClientTests raopClientTests;