diff --git a/src/libsync/abstractnetworkjob.cpp b/src/libsync/abstractnetworkjob.cpp index 199f7c0546d9c..ebe9b9997eb29 100644 --- a/src/libsync/abstractnetworkjob.cpp +++ b/src/libsync/abstractnetworkjob.cpp @@ -110,6 +110,40 @@ void AbstractNetworkJob::setupConnections(QNetworkReply *reply) connect(reply, &QNetworkReply::downloadProgress, this, &AbstractNetworkJob::networkActivity); connect(reply, &QNetworkReply::uploadProgress, this, &AbstractNetworkJob::networkActivity); connect(reply, &QNetworkReply::redirected, this, [reply, this] (const QUrl &url) { emit redirected(reply, url, 0);}); + + if (_stallDetectionEnabled) { + // Reset per-reply state so progress from a previous attempt does not + // carry over (e.g. after an HTTP redirect). + _lastTransferBytes = -1; + connect(reply, &QNetworkReply::uploadProgress, this, [this](qint64 bytesSent, qint64) { + if (bytesSent > _lastTransferBytes) { + _lastTransferBytes = bytesSent; + _stallTimer.start(); + } + }); + connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 bytesReceived, qint64) { + if (bytesReceived > _lastTransferBytes) { + _lastTransferBytes = bytesReceived; + _stallTimer.start(); + } + }); + _stallTimer.start(); + } +} + +void AbstractNetworkJob::enableStallDetection(int timeoutMs) +{ + _stallDetectionEnabled = true; + _stallTimer.setSingleShot(true); + _stallTimer.setInterval(timeoutMs); + connect(&_stallTimer, &QTimer::timeout, this, [this]() { + qCWarning(lcNetworkJob) << "Transfer stalled: no bytes transferred for" + << _stallTimer.interval() / 1000 << "seconds on" << path(); + emit transferStalled(); + if (reply()) { + reply()->abort(); + } + }); } QNetworkReply *AbstractNetworkJob::addTimer(QNetworkReply *reply) @@ -175,6 +209,7 @@ QUrl AbstractNetworkJob::makeDavUrl(const QString &relativePath) const void AbstractNetworkJob::slotFinished() { _timer.stop(); + _stallTimer.stop(); if (_reply->error() == QNetworkReply::SslHandshakeFailedError) { qCWarning(lcNetworkJob) << "SslHandshakeFailedError: " << errorString() << " : can be caused by a webserver wanting SSL client certificates"; diff --git a/src/libsync/abstractnetworkjob.h b/src/libsync/abstractnetworkjob.h index e65cf06edd6f4..804bcc623591c 100644 --- a/src/libsync/abstractnetworkjob.h +++ b/src/libsync/abstractnetworkjob.h @@ -110,6 +110,19 @@ class OWNCLOUDSYNC_EXPORT AbstractNetworkJob : public QObject /// Returns a standardised error message in case of HSTS errors [[nodiscard]] static std::optional hstsErrorStringFromReply(QNetworkReply *reply); +public: + /** + * Enables per-job stall detection for uploads and downloads. + * + * Once enabled, a timer is started when the job begins transferring data. + * The timer is reset whenever progress is made. If no bytes are transferred + * within @p timeoutMs milliseconds, the transferStalled() signal is emitted + * and the reply is aborted. + * + * Call this from a subclass's start() before invoking sendRequest(). + */ + void enableStallDetection(int timeoutMs = 30 * 1000); + public slots: void setTimeout(qint64 msec); void resetTimeout(); @@ -121,6 +134,12 @@ public slots: void networkError(QNetworkReply *reply); void networkActivity(); + /** + * Emitted when stall detection is active and no bytes have been transferred + * within the configured timeout. The reply is aborted immediately after. + */ + void transferStalled(); + /** Emitted when a redirect is followed. * * \a reply The "please redirect" reply @@ -220,6 +239,10 @@ private slots: // // Reparented to the currently running QNetworkReply. QPointer _requestBody; + + QTimer _stallTimer; + qint64 _lastTransferBytes = -1; + bool _stallDetectionEnabled = false; }; /** diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index c5bd0a4043f1e..a6a07e372aed2 100644 --- a/src/libsync/propagatedownload.cpp +++ b/src/libsync/propagatedownload.cpp @@ -119,6 +119,8 @@ void GETFileJob::start() req.setPriority(QNetworkRequest::LowPriority); // Long downloads must not block non-propagation jobs. req.setDecompressedSafetyCheckThreshold(_decompressionThresholdBase + CustomDecompressedSafetyCheckThreshold); + enableStallDetection(); + if (_directDownloadUrl.isEmpty()) { sendRequest("GET", makeDavUrl(path()), req); } else { @@ -745,6 +747,11 @@ void PropagateDownloadFile::startDownload() _job->setBandwidthManager(&propagator()->_bandwidthManager); connect(_job.data(), &GETFileJob::finishedSignal, this, &PropagateDownloadFile::slotGetFinished); connect(_job.data(), &GETFileJob::downloadProgress, this, &PropagateDownloadFile::slotDownloadProgress); + connect(_job.data(), &AbstractNetworkJob::transferStalled, this, [this]() { + qCWarning(lcPropagateDownload) << "Download stalled for" << _item->_file << "- scheduling retry."; + _job->cancel(); + done(SyncFileItem::SoftError, tr("Download stalled: no data was transferred. The download will be retried."), ErrorCategory::GenericError); + }); propagator()->_activeJobList.append(this); _job->start(); } diff --git a/src/libsync/propagateupload.cpp b/src/libsync/propagateupload.cpp index ada532290431d..a7ce567ebaa75 100644 --- a/src/libsync/propagateupload.cpp +++ b/src/libsync/propagateupload.cpp @@ -57,6 +57,8 @@ void PUTFileJob::start() req.setPriority(QNetworkRequest::LowPriority); // Long uploads must not block non-propagation jobs. + enableStallDetection(); + auto requestID = QByteArray{}; if (_url.isValid()) { diff --git a/src/libsync/propagateuploadng.cpp b/src/libsync/propagateuploadng.cpp index 0582611616cf6..8da577aecd9ef 100644 --- a/src/libsync/propagateuploadng.cpp +++ b/src/libsync/propagateuploadng.cpp @@ -391,6 +391,10 @@ void PropagateUploadFileNG::startNextChunk() connect(job, &PUTFileJob::uploadProgress, devicePtr, &UploadDevice::slotJobUploadProgress); connect(job, &QObject::destroyed, this, &PropagateUploadFileCommon::slotJobDestroyed); + connect(job, &AbstractNetworkJob::transferStalled, this, [this]() { + qCWarning(lcPropagateUploadNG) << "Upload stalled for" << _item->_file << "- scheduling retry."; + abortWithError(SyncFileItem::SoftError, tr("Upload stalled: no data was transferred. The upload will be retried.")); + }); job->start(); propagator()->_activeJobList.append(this); _currentChunk++; diff --git a/src/libsync/propagateuploadv1.cpp b/src/libsync/propagateuploadv1.cpp index ac6b97426bf7c..8bc80379d35ff 100644 --- a/src/libsync/propagateuploadv1.cpp +++ b/src/libsync/propagateuploadv1.cpp @@ -160,6 +160,10 @@ void PropagateUploadFileV1::startNextChunk() connect(job, &PUTFileJob::uploadProgress, this, &PropagateUploadFileV1::slotUploadProgress); connect(job, &PUTFileJob::uploadProgress, devicePtr, &UploadDevice::slotJobUploadProgress); connect(job, &QObject::destroyed, this, &PropagateUploadFileCommon::slotJobDestroyed); + connect(job, &AbstractNetworkJob::transferStalled, this, [this]() { + qCWarning(lcPropagateUploadV1) << "Upload stalled for" << _item->_file << "- scheduling retry."; + abortWithError(SyncFileItem::SoftError, tr("Upload stalled: no data was transferred. The upload will be retried.")); + }); if (isFinalChunk) adjustLastJobTimeout(job, fileSize); job->start();