cspot: delayed Pong (like librespot) stops the 6-minute connection resets

The Spotify AP reset the connection (recv -1, errno 104 ECONNRESET) on
the third Ping, every 6 minutes, like clockwork. cspot answered each
Ping at once; librespot (core/src/session.rs) waits 60 s:
Ping -> 60 s -> Pong -> PongAck -> 60 s -> Ping.
- 0003-delayed-pong: record the Ping, send the Pong 60 s later from
  triggerTimeout() (called on every 3 s receive timeout), through the
  PR #3 connection snapshot; dropped on reconnect.
- 0002-diag-log-recv-errors: log recv's return value/errno before
  "Error in read" (error path only); this is what showed the RST.
Verified: over 8+ minutes Ping/delayed Pong/PongAck every 2 min, no
reset at the 6-minute mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:47:27 +10:00
parent 8008aa175f
commit 5ce4588c80
2 changed files with 89 additions and 0 deletions
@@ -0,0 +1,15 @@
diff --git a/cspot/src/PlainConnection.cpp b/cspot/src/PlainConnection.cpp
index 1bff6cf..b4ee6ff 100644
--- a/cspot/src/PlainConnection.cpp
+++ b/cspot/src/PlainConnection.cpp
@@ -150,6 +150,10 @@ void PlainConnection::readBlock(const uint8_t* dst, size_t size) {
case EINTR:
break;
default:
+ // diagnostic (project patch): n == 0 means the peer closed (FIN); n < 0 with errno
+ // e.g. ECONNRESET (RST). Note errno is not updated when n == 0.
+ CSPOT_LOG(error, "recv returned %d, errno %d, %u of %u bytes, retry %d", (int)n,
+ getErrno(), idx, (unsigned)size, retries);
if (retries++ > 4)
throw std::runtime_error("Error in read");
goto READ;
@@ -0,0 +1,74 @@
Keep-alive like librespot (core/src/session.rs): the server sends a Ping every ~2 min; send
the Pong 60 s later, not at once. With an immediate Pong the server reset the AP connection
(ECONNRESET) on the third Ping, i.e. every 6 minutes, and the Spotify app lost the device.
--- a/cspot/include/MercurySession.h
+++ b/cspot/include/MercurySession.h
@@ -120,6 +120,14 @@
unsigned long long timestampDiff;
unsigned long long lastPingTimestamp = -1;
+
+ // Keep-alive as librespot does it: Ping -> wait 60 s -> Pong -> PongAck. Answering at once made
+ // the server reset the connection on the third Ping (every 6 minutes).
+ const int PONG_DELAY_MS = 60 * 1000;
+ bool pongPending = false;
+ unsigned long long pongDueTimestamp = 0;
+ std::vector<uint8_t> pongData;
+ void sendPendingPong();
std::string countryCode = "";
std::mutex isRunningMutex;
--- a/cspot/src/MercurySession.cpp
+++ b/cspot/src/MercurySession.cpp
@@ -45,7 +45,10 @@
timeProvider->syncWithPingPacket(packet.data);
this->lastPingTimestamp = timeProvider->getSyncedTimestamp();
- this->shanConn->sendPacket(0x49, packet.data);
+ // Pong 60 s later (sent from triggerTimeout(), called on every 3 s receive timeout)
+ this->pongData = packet.data;
+ this->pongDueTimestamp = this->lastPingTimestamp + PONG_DELAY_MS;
+ this->pongPending = true;
} else {
this->packetQueue.push(packet);
}
@@ -80,6 +83,7 @@
BELL_SLEEP_MS(100);
lastPingTimestamp = timeProvider->getSyncedTimestamp();
+ pongPending = false; // a Ping on the old connection needs no Pong on the new one
isReconnecting = false;
this->executeEstabilishedCallback = true;
@@ -98,9 +102,30 @@
this->connectionReadyCallback = callback;
}
+void MercurySession::sendPendingPong() {
+ if (!pongPending ||
+ timeProvider->getSyncedTimestamp() < pongDueTimestamp)
+ return;
+ pongPending = false;
+ std::shared_ptr<ShannonConnection> shanConn;
+ {
+ std::scoped_lock lock(connMutex);
+ shanConn = this->shanConn;
+ }
+ if (!shanConn)
+ return;
+ try {
+ shanConn->sendPacket(0x49, pongData);
+ CSPOT_LOG(info, "Sent delayed Pong");
+ } catch (...) {
+ CSPOT_LOG(error, "Failed to send Pong");
+ }
+}
+
bool MercurySession::triggerTimeout() {
if (!isRunning)
return true;
+ sendPendingPong();
auto currentTimestamp = timeProvider->getSyncedTimestamp();
if (currentTimestamp - this->lastPingTimestamp > PING_TIMEOUT_MS) {