From 05df6a7ada17ddebe77f9a9cedd7ea31c35ec823 Mon Sep 17 00:00:00 2001 From: Olivier Goffart Date: Mon, 25 Jun 2018 17:47:52 +0200 Subject: [PATCH] Upload: assynchronious operations Implements https://github.com/owncloud/core/pull/31851 --- src/libsync/owncloudpropagator.cpp | 13 +- src/libsync/propagateupload.cpp | 29 ++-- src/libsync/propagateupload.h | 6 +- src/libsync/propagateuploadng.cpp | 12 ++ src/libsync/propagateuploadv1.cpp | 2 +- test/CMakeLists.txt | 1 + test/syncenginetestutils.h | 89 +++++++++--- test/testasyncop.cpp | 223 +++++++++++++++++++++++++++++ 8 files changed, 332 insertions(+), 43 deletions(-) create mode 100644 test/testasyncop.cpp diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index 194346d2724..2f28d11a7b7 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -1002,12 +1002,12 @@ void CleanupPollsJob::start() auto info = _pollInfos.first(); _pollInfos.pop_front(); SyncJournalFileRecord record; - if (_journal->getFileRecord(info._file, &record) && record.isValid()) { - SyncFileItemPtr item = SyncFileItem::fromSyncJournalFileRecord(record); - PollJob *job = new PollJob(_account, info._url, item, _journal, _localPath, this); - connect(job, &PollJob::finishedSignal, this, &CleanupPollsJob::slotPollFinished); - job->start(); - } + SyncFileItemPtr item(new SyncFileItem); + item->_file = info._file; + item->_modtime = info._modtime; + PollJob *job = new PollJob(_account, info._url, item, _journal, _localPath, this); + connect(job, &PollJob::finishedSignal, this, &CleanupPollsJob::slotPollFinished); + job->start(); } void CleanupPollsJob::slotPollFinished() @@ -1029,6 +1029,7 @@ void CleanupPollsJob::slotPollFinished() deleteLater(); return; } + _journal->setUploadInfo(job->_item->_file, SyncJournalDb::UploadInfo()); } // Continue with the next entry, or finish start(); diff --git a/src/libsync/propagateupload.cpp b/src/libsync/propagateupload.cpp index c2d5c387df3..15893f7fa38 100644 --- a/src/libsync/propagateupload.cpp +++ b/src/libsync/propagateupload.cpp @@ -98,7 +98,7 @@ void PollJob::start() QUrl finalUrl = QUrl::fromUserInput(accountUrl.scheme() + QLatin1String("://") + accountUrl.authority() + (path().startsWith('/') ? QLatin1String("") : QLatin1String("/")) + path()); sendRequest("GET", finalUrl); - connect(reply(), &QNetworkReply::downloadProgress, this, &AbstractNetworkJob::resetTimeout); + connect(reply(), &QNetworkReply::downloadProgress, this, &AbstractNetworkJob::resetTimeout, Qt::UniqueConnection); AbstractNetworkJob::start(); } @@ -123,14 +123,14 @@ bool PollJob::finished() emit finishedSignal(); return true; } - start(); + QTimer::singleShot(8 * 1000, this, &PollJob::start); return false; } QByteArray jsonData = reply()->readAll().trimmed(); - qCInfo(lcPollJob) << ">" << jsonData << "<" << reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); QJsonParseError jsonParseError; - QJsonObject status = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); + QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); + qCInfo(lcPollJob) << ">" << jsonData << "<" << reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() << json << jsonParseError.errorString(); if (jsonParseError.error != QJsonParseError::NoError) { _item->_errorString = tr("Invalid JSON reply from the poll URL"); _item->_status = SyncFileItem::NormalError; @@ -138,16 +138,23 @@ bool PollJob::finished() return true; } - if (status["unfinished"].toBool()) { - start(); + auto status = json["status"].toString(); + if (status == QLatin1String("init") || status == QLatin1String("started")) { + QTimer::singleShot(5 * 1000, this, &PollJob::start); return false; } - _item->_errorString = status["error"].toString(); - _item->_status = _item->_errorString.isEmpty() ? SyncFileItem::Success : SyncFileItem::NormalError; - _item->_fileId = status["fileid"].toString().toUtf8(); - _item->_etag = status["etag"].toString().toUtf8(); _item->_responseTimeStamp = responseTimestamp(); + _item->_httpErrorCode = json["errorCode"].toInt(); + + if (status == QLatin1String("finished")) { + _item->_status = SyncFileItem::Success; + _item->_fileId = json["fileId"].toString().toUtf8(); + _item->_etag = parseEtag(json["ETag"].toString().toUtf8()); + } else { // error + _item->_status = classifyError(QNetworkReply::UnknownContentError, _item->_httpErrorCode); + _item->_errorString = json["errorMessage"].toString(); + } SyncJournalDb::PollInfo info; info._file = _item->_file; @@ -571,7 +578,7 @@ void PropagateUploadFileCommon::abortWithError(SyncFileItem::Status status, cons QMap PropagateUploadFileCommon::headers() { QMap headers; - headers["OC-Async"] = "1"; + headers["OC-LazyOps"] = "true"; headers["Content-Type"] = "application/octet-stream"; headers["X-OC-Mtime"] = QByteArray::number(qint64(_item->_modtime)); diff --git a/src/libsync/propagateupload.h b/src/libsync/propagateupload.h index b0c609d2c2c..eb69134fa7a 100644 --- a/src/libsync/propagateupload.h +++ b/src/libsync/propagateupload.h @@ -152,8 +152,8 @@ class PUTFileJob : public AbstractNetworkJob /** * @brief This job implements the asynchronous PUT * - * If the server replies to a PUT with a OC-Finish-Poll url, we will query this url until the server - * replies with an etag. https://github.com/owncloud/core/issues/12097 + * If the server replies to a PUT with a OC-JobStatus-Location path, we will query this url until the server + * replies with an etag. * @ingroup libsync */ class PollJob : public AbstractNetworkJob @@ -285,7 +285,7 @@ private slots: */ static void adjustLastJobTimeout(AbstractNetworkJob *job, quint64 fileSize); - // Bases headers that need to be sent with every chunk + /** Bases headers that need to be sent on the PUT, or in the MOVE for chunking-ng */ QMap headers(); }; diff --git a/src/libsync/propagateuploadng.cpp b/src/libsync/propagateuploadng.cpp index f9963ca0fc5..1218030369e 100644 --- a/src/libsync/propagateuploadng.cpp +++ b/src/libsync/propagateuploadng.cpp @@ -455,6 +455,18 @@ void PropagateUploadFileNG::slotMoveJobFinished() commonErrorHandling(job); return; } + + if (_item->_httpErrorCode == 202) { + QString path = QString::fromUtf8(job->reply()->rawHeader("OC-JobStatus-Location")); + if (path.isEmpty()) { + done(SyncFileItem::NormalError, tr("Poll URL missing")); + return; + } + _finished = true; + startPollJob(path); + return; + } + if (_item->_httpErrorCode != 201 && _item->_httpErrorCode != 204) { abortWithError(SyncFileItem::NormalError, tr("Unexpected return code from server (%1)").arg(_item->_httpErrorCode)); return; diff --git a/src/libsync/propagateuploadv1.cpp b/src/libsync/propagateuploadv1.cpp index d3d1dbaf19f..e5564c71ecc 100644 --- a/src/libsync/propagateuploadv1.cpp +++ b/src/libsync/propagateuploadv1.cpp @@ -211,7 +211,7 @@ void PropagateUploadFileV1::slotPutFinished() // The server needs some time to process the request and provide us with a poll URL if (_item->_httpErrorCode == 202) { - QString path = QString::fromUtf8(job->reply()->rawHeader("OC-Finish-Poll")); + QString path = QString::fromUtf8(job->reply()->rawHeader("OC-JobStatus-Location")); if (path.isEmpty()) { done(SyncFileItem::NormalError, tr("Poll URL missing")); return; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0b923b85ce8..e92b6f5909c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -48,6 +48,7 @@ owncloud_add_test(SyncConflict "syncenginetestutils.h") owncloud_add_test(SyncFileStatusTracker "syncenginetestutils.h") owncloud_add_test(Download "syncenginetestutils.h") owncloud_add_test(ChunkingNg "syncenginetestutils.h") +owncloud_add_test(AsyncOp "syncenginetestutils.h") owncloud_add_test(UploadReset "syncenginetestutils.h") owncloud_add_test(AllFilesDeleted "syncenginetestutils.h") owncloud_add_test(Blacklist "syncenginetestutils.h") diff --git a/test/syncenginetestutils.h b/test/syncenginetestutils.h index f1790b58857..d76e13d51f7 100644 --- a/test/syncenginetestutils.h +++ b/test/syncenginetestutils.h @@ -41,7 +41,7 @@ inline QString getFilePathFromUrl(const QUrl &url) { inline QString generateEtag() { - return QString::number(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch(), 16); + return QString::number(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch(), 16) + QByteArray::number(qrand(), 16); } inline QByteArray generateFileId() { return QByteArray::number(qrand(), 16); @@ -233,7 +233,7 @@ class FileInfo : public FileModifier auto file = it->find(pathComponents.subComponents(), invalidateEtags); if (file && invalidateEtags) // Update parents on the way back - etag = file->etag; + etag = generateEtag(); return file; } return 0; @@ -302,7 +302,6 @@ class FileInfo : public FileModifier QMap children; QString parentPath; -private: FileInfo *findInvalidatingEtags(const PathComponents &pathComponents) { return find(pathComponents, true); } @@ -424,24 +423,25 @@ class FakePutReply : public QNetworkReply setUrl(request.url()); setOperation(op); open(QIODevice::ReadOnly); + fileInfo = perform(remoteRootFileInfo, request, putPayload); + QMetaObject::invokeMethod(this, "respond", Qt::QueuedConnection); + } + static FileInfo *perform(FileInfo &remoteRootFileInfo, const QNetworkRequest &request, const QByteArray &putPayload) + { QString fileName = getFilePathFromUrl(request.url()); Q_ASSERT(!fileName.isEmpty()); - if ((fileInfo = remoteRootFileInfo.find(fileName))) { + FileInfo *fileInfo = remoteRootFileInfo.find(fileName); + if (fileInfo) { fileInfo->size = putPayload.size(); fileInfo->contentChar = putPayload.at(0); } else { // Assume that the file is filled with the same character fileInfo = remoteRootFileInfo.create(fileName, putPayload.size(), putPayload.at(0)); } - - if (!fileInfo) { - abort(); - return; - } fileInfo->lastModified = OCC::Utility::qDateTimeFromTime_t(request.rawHeader("X-OC-Mtime").toLongLong()); remoteRootFileInfo.find(fileName, /*invalidate_etags=*/true); - QMetaObject::invokeMethod(this, "respond", Qt::QueuedConnection); + return fileInfo; } Q_INVOKABLE virtual void respond() @@ -631,7 +631,16 @@ class FakeChunkMoveReply : public QNetworkReply setUrl(request.url()); setOperation(op); open(QIODevice::ReadOnly); + fileInfo = perform(uploadsFileInfo, remoteRootFileInfo, request); + if (!fileInfo) { + QTimer::singleShot(0, this, &FakeChunkMoveReply::respondPreconditionFailed); + } else { + QTimer::singleShot(0, this, &FakeChunkMoveReply::respond); + } + } + static FileInfo *perform(FileInfo &uploadsFileInfo, FileInfo &remoteRootFileInfo, const QNetworkRequest &request) + { QString source = getFilePathFromUrl(request.url()); Q_ASSERT(!source.isEmpty()); Q_ASSERT(source.endsWith("/.file")); @@ -657,17 +666,16 @@ class FakeChunkMoveReply : public QNetworkReply } while(true); Q_ASSERT(count > 1); // There should be at least two chunks, otherwise why would we use chunking? - QCOMPARE(sourceFolder->children.count(), count); // There should not be holes or extra files + Q_ASSERT(sourceFolder->children.count() == count); // There should not be holes or extra files QString fileName = getFilePathFromUrl(QUrl::fromEncoded(request.rawHeader("Destination"))); Q_ASSERT(!fileName.isEmpty()); - - if ((fileInfo = remoteRootFileInfo.find(fileName))) { - QVERIFY(request.hasRawHeader("If")); // The client should put this header + FileInfo *fileInfo = remoteRootFileInfo.find(fileName); + if (fileInfo) { + Q_ASSERT(request.hasRawHeader("If")); // The client should put this header if (request.rawHeader("If") != QByteArray("<" + request.rawHeader("Destination") + "> ([\"" + fileInfo->etag.toLatin1() + "\"])")) { - QMetaObject::invokeMethod(this, "respondPreconditionFailed", Qt::QueuedConnection); - return; + return nullptr; } fileInfo->size = size; fileInfo->contentChar = payload; @@ -676,15 +684,10 @@ class FakeChunkMoveReply : public QNetworkReply // Assume that the file is filled with the same character fileInfo = remoteRootFileInfo.create(fileName, size, payload); } - - if (!fileInfo) { - abort(); - return; - } fileInfo->lastModified = OCC::Utility::qDateTimeFromTime_t(request.rawHeader("X-OC-Mtime").toLongLong()); remoteRootFileInfo.find(fileName, /*invalidate_etags=*/true); - QTimer::singleShot(0, this, &FakeChunkMoveReply::respond); + return fileInfo; } Q_INVOKABLE virtual void respond() @@ -714,6 +717,48 @@ class FakeChunkMoveReply : public QNetworkReply }; +class FakePayloadReply : public QNetworkReply +{ + Q_OBJECT +public: + FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, + const QByteArray &body, QObject *parent) + : QNetworkReply{ parent } + , _body(body) + { + setRequest(request); + setUrl(request.url()); + setOperation(op); + open(QIODevice::ReadOnly); + QTimer::singleShot(10, this, &FakePayloadReply::respond); + } + + void respond() + { + setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 200); + setHeader(QNetworkRequest::ContentLengthHeader, _body.size()); + emit metaDataChanged(); + emit readyRead(); + setFinished(true); + emit finished(); + } + + void abort() override {} + qint64 readData(char *buf, qint64 max) override + { + max = qMin(max, _body.size()); + memcpy(buf, _body.constData(), max); + _body = _body.mid(max); + return max; + } + qint64 bytesAvailable() const override + { + return _body.size(); + } + QByteArray _body; +}; + + class FakeErrorReply : public QNetworkReply { Q_OBJECT diff --git a/test/testasyncop.cpp b/test/testasyncop.cpp new file mode 100644 index 00000000000..86a6487839d --- /dev/null +++ b/test/testasyncop.cpp @@ -0,0 +1,223 @@ +/* + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + * + */ + +#include +#include "syncenginetestutils.h" +#include + +using namespace OCC; + +class FakeAsyncReply : public QNetworkReply +{ + Q_OBJECT + QByteArray _pollLocation; + +public: + FakeAsyncReply(const QByteArray &pollLocation, QNetworkAccessManager::Operation op, const QNetworkRequest &request, QObject *parent) + : QNetworkReply{ parent } + , _pollLocation(pollLocation) + { + setRequest(request); + setUrl(request.url()); + setOperation(op); + open(QIODevice::ReadOnly); + + QMetaObject::invokeMethod(this, "respond", Qt::QueuedConnection); + } + + Q_INVOKABLE void respond() + { + setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 202); + setRawHeader("OC-JobStatus-Location", _pollLocation); + emit metaDataChanged(); + emit finished(); + } + + void abort() override {} + qint64 readData(char *, qint64) override { return 0; } +}; + + +class TestAsyncOp : public QObject +{ + Q_OBJECT + +private slots: + + void asyncOperations() + { + FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() }; + fakeFolder.syncEngine().account()->setCapabilities({ { "dav", QVariantMap{ { "chunking", "1.0" } } } }); + // Reduce max chunk size a bit so we get more chunks + SyncOptions options; + options._maxChunkSize = 20 * 1000; + fakeFolder.syncEngine().setSyncOptions(options); + int nGET = 0; + + struct TestCase + { + std::function pollRequest; + std::function perform = nullptr; + }; + QHash testCases; + + fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &request, QIODevice *outgoingData) -> QNetworkReply * { + auto path = request.url().path(); + + if (op == QNetworkAccessManager::GetOperation && path.startsWith("/async-poll/")) { + auto file = path.mid(sizeof("/async-poll/") - 1); + Q_ASSERT(testCases.contains(file)); + auto &testCase = testCases[file]; + return testCase.pollRequest(&testCase, request); + } + + if (op == QNetworkAccessManager::PutOperation && !path.contains("/uploads/")) { + // Not chunking + auto file = getFilePathFromUrl(request.url()); + Q_ASSERT(testCases.contains(file)); + auto &testCase = testCases[file]; + Q_ASSERT(!testCase.perform); + auto putPayload = outgoingData->readAll(); + testCase.perform = [putPayload, request, &fakeFolder] { + return FakePutReply::perform(fakeFolder.remoteModifier(), request, putPayload); + }; + return new FakeAsyncReply("/async-poll/" + file.toUtf8(), op, request, &fakeFolder.syncEngine()); + } else if (request.attribute(QNetworkRequest::CustomVerbAttribute) == "MOVE") { + QString file = getFilePathFromUrl(QUrl::fromEncoded(request.rawHeader("Destination"))); + Q_ASSERT(testCases.contains(file)); + auto &testCase = testCases[file]; + Q_ASSERT(!testCase.perform); + testCase.perform = [request, &fakeFolder] { + return FakeChunkMoveReply::perform(fakeFolder.uploadState(), fakeFolder.remoteModifier(), request); + }; + return new FakeAsyncReply("/async-poll/" + file.toUtf8(), op, request, &fakeFolder.syncEngine()); + } else if (op == QNetworkAccessManager::GetOperation) { + nGET++; + } + return nullptr; + }); + + + auto successCallbak = [](TestCase *tc, const QNetworkRequest &request) { + tc->pollRequest = [](auto...) -> QNetworkReply * { std::abort(); }; // shall no longer be called + FileInfo *info = tc->perform(); + QByteArray body = "{ \"status\":\"finished\", \"ETag\":\"\\\"" + info->etag.toUtf8() + "\\\"\", \"fileId\":\"" + info->fileId + "\"}\n"; + return new FakePayloadReply(QNetworkAccessManager::GetOperation, request, body, nullptr); + }; + auto waitForeverCallBack = [](TestCase *, const QNetworkRequest &request) { + QByteArray body = "{\"status\":\"started\"}\n"; + return new FakePayloadReply(QNetworkAccessManager::GetOperation, request, body, nullptr); + }; + auto errorCallBack = [](TestCase *tc, const QNetworkRequest &request) { + tc->pollRequest = [](auto...) -> QNetworkReply * { std::abort(); }; // shall no longer be called; + QByteArray body = "{\"status\":\"error\",\"errorCode\":500,\"errorMessage\":\"TestingErrors\"}\n"; + return new FakePayloadReply(QNetworkAccessManager::GetOperation, request, body, nullptr); + }; + auto waitAndChain = [](const auto &chain) { + return [chain](TestCase *tc, const QNetworkRequest &request) { + tc->pollRequest = chain; + QByteArray body = "{\"status\":\"started\"}\n"; + return new FakePayloadReply(QNetworkAccessManager::GetOperation, request, body, nullptr); + }; + }; + + auto insertFile = [&](const QString &file, int size, auto cb) { + fakeFolder.localModifier().insert(file, size); + testCases[file] = { cb }; + }; + fakeFolder.localModifier().mkdir("success"); + insertFile("success/chunked_success", options._maxChunkSize * 3, successCallbak); + insertFile("success/single_success", 300, successCallbak); + insertFile("success/chunked_patience", options._maxChunkSize * 3, + waitAndChain(waitAndChain(successCallbak))); + insertFile("success/single_patience", 300, + waitAndChain(waitAndChain(successCallbak))); + fakeFolder.localModifier().mkdir("err"); + insertFile("err/chunked_error", options._maxChunkSize * 3, errorCallBack); + insertFile("err/single_error", 300, errorCallBack); + insertFile("err/chunked_error2", options._maxChunkSize * 3, waitAndChain(errorCallBack)); + insertFile("err/single_error2", 300, waitAndChain(errorCallBack)); + + // First sync should finish by itself. + // All the things in "success/" should be transfered, the things in "err/" ,not + QVERIFY(!fakeFolder.syncOnce()); + QCOMPARE(nGET, 0); + QCOMPARE(*fakeFolder.currentLocalState().find("success"), + *fakeFolder.currentRemoteState().find("success")); + testCases.clear(); + testCases["err/chunked_error"] = { successCallbak }; + testCases["err/chunked_error2"] = { successCallbak }; + testCases["err/single_error"] = { successCallbak }; + testCases["err/single_error2"] = { successCallbak }; + + fakeFolder.localModifier().mkdir("waiting"); + insertFile("waiting/small", 300, waitForeverCallBack); + insertFile("waiting/willNotConflict", 300, waitForeverCallBack); + insertFile("waiting/big", options._maxChunkSize * 3, + waitAndChain(waitAndChain([&](TestCase *tc, const QNetworkRequest &request) { + QTimer::singleShot(0, &fakeFolder.syncEngine(), &SyncEngine::abort); + return waitAndChain(waitForeverCallBack)(tc, request); + }))); + + fakeFolder.syncJournal().wipeErrorBlacklist(); + + // This second sync will redo the files that had errors + // But the waiting folder will not complete before it is aborted. + QVERIFY(!fakeFolder.syncOnce()); + QCOMPARE(nGET, 0); + QCOMPARE(*fakeFolder.currentLocalState().find("err"), + *fakeFolder.currentRemoteState().find("err")); + + testCases["waiting/small"].pollRequest = waitAndChain(waitAndChain(successCallbak)); + testCases["waiting/big"].pollRequest = waitAndChain(successCallbak); + testCases["waiting/willNotConflict"].pollRequest = + [&fakeFolder, &successCallbak](TestCase *tc, const QNetworkRequest &request) { + auto &remoteModifier = fakeFolder.remoteModifier(); // successCallbak destroyes the capture + auto reply = successCallbak(tc, request); + // This is going to succeed, and after we just change the file. + // This should not be a conflict, but this should be downloaded in the + // next sync + remoteModifier.appendByte("waiting/willNotConflict"); + return reply; + }; + + + int nPUT = 0; + int nMOVE = 0; + int nDELETE = 0; + fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &request, QIODevice *) -> QNetworkReply * { + auto path = request.url().path(); + if (op == QNetworkAccessManager::GetOperation && path.startsWith("/async-poll/")) { + auto file = path.mid(sizeof("/async-poll/") - 1); + Q_ASSERT(testCases.contains(file)); + auto &testCase = testCases[file]; + return testCase.pollRequest(&testCase, request); + } else if (op == QNetworkAccessManager::PutOperation) { + nPUT++; + } else if (op == QNetworkAccessManager::GetOperation) { + nGET++; + } else if (op == QNetworkAccessManager::DeleteOperation) { + nDELETE++; + } else if (request.attribute(QNetworkRequest::CustomVerbAttribute) == "MOVE") { + nMOVE++; + } + return nullptr; + }); + + + // This last sync will do the waiting stuff + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(nGET, 1); // "waiting/willNotConflict" + QCOMPARE(nPUT, 0); + QCOMPARE(nMOVE, 0); + QCOMPARE(nDELETE, 0); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + } +}; + +QTEST_GUILESS_MAIN(TestAsyncOp) +#include "testasyncop.moc"