#include "common/ipc.h" #include "common/sandbox.h" #include "common/worker_process.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace docview; namespace { int stalledPeer(QCoreApplication &app) { const auto args = app.arguments(); const int socketIndex = args.indexOf("--socket"); if (socketIndex < 0 || socketIndex + 1 >= args.size()) return 120; QLocalSocket socket; socket.connectToServer(args[socketIndex + 1]); if (!socket.waitForConnected(5000)) return 121; QString error; if (!enterSandbox({}, {}, &error)) return 122; IpcChannel channel(&socket); QCborMap extracting; QTimer chunks; chunks.setInterval(1000); const auto reply = [&](QCborMap request, const QCborMap &result) { request.remove(QStringLiteral("payload")); request.insert(QStringLiteral("result"), result); if (!channel.send(request)) std::_Exit(123); }; QObject::connect(&chunks, &QTimer::timeout, &app, [&] { // Progress is deliberately nonterminal: it must not reset the resource // deadline or allow a queued request to overtake this extraction. reply(extracting, {{"more", true}, {"data", QByteArray("progress")}}); }); QObject::connect(&socket, &QLocalSocket::disconnected, &app, &QCoreApplication::quit); QObject::connect(&channel, &IpcChannel::protocolError, &app, [] { std::_Exit(124); }); QObject::connect(&channel, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { const auto op = request.value("operation").toString(); if (op == "ping") reply(request, {{"sandboxed", true}, {"pid", QCoreApplication::applicationPid()}}); else if (op == "extract") { extracting = request; chunks.start(); } // Rendering deliberately never replies. Keep the real socket and // event loop alive until the production broker terminates this peer. }); return app.exec(); } QString digest(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {}; QCryptographicHash hash(QCryptographicHash::Sha256); if (!hash.addData(&file)) return {}; return QString::fromLatin1(hash.result().toHex()); } } // namespace class WorkerDeadlinesTest : public QObject { Q_OBJECT private slots: void realBudgetsKeepTheEventLoopResponsive() { QTemporaryDir directory; QVERIFY(directory.isValid()); const auto archivePath = directory.filePath("docview-archive-worker"); QVERIFY(QFile::link(QCoreApplication::applicationFilePath(), archivePath)); WorkerProcess pdf("deadline-pdf", 1), archive("deadline-archive", 1), cancelled("cancel-pdf", 1); QSignalSpy pdfFailures(&pdf, &WorkerProcess::failed), archiveFailures(&archive, &WorkerProcess::failed); QSignalSpy cancelFailures(&cancelled, &WorkerProcess::failed); qint64 pdfPid = 0, archivePid = 0, cancelPid = 0; int handshakes = 0; for (auto pair : {qMakePair(&pdf, &pdfPid), qMakePair(&archive, &archivePid), qMakePair(&cancelled, &cancelPid)}) { QVERIFY(pair.first->start(pair.first == &archive ? archivePath : QCoreApplication::applicationFilePath(), {"--stalled-peer"})); pair.first->request("ping", {}, [&, pid = pair.second](const QCborMap &value) { const auto result = value.value("result").toMap(); if (result.value("sandboxed").toBool()) { *pid = result.value("pid").toInteger(); ++handshakes; } }); } QTRY_COMPARE_WITH_TIMEOUT(handshakes, 3, 5000); QElapsedTimer clock; clock.start(); qint64 pdfSlow = -1, archiveSlow = -1, pdfStopped = -1, archiveStopped = -1; qint64 cancelDuration = -1, lastHeartbeat = 0, maxHeartbeatGap = 0; int pdfSlowCount = 0, archiveSlowCount = 0, terminalReplies = 0, chunks = 0, heartbeats = 0; connect(&pdf, &WorkerProcess::slow, this, [&] { pdfSlow = clock.elapsed(); ++pdfSlowCount; }); connect(&archive, &WorkerProcess::slow, this, [&] { archiveSlow = clock.elapsed(); ++archiveSlowCount; }); connect(&pdf, &WorkerProcess::failed, this, [&] { pdfStopped = clock.elapsed(); }); connect(&archive, &WorkerProcess::failed, this, [&] { archiveStopped = clock.elapsed(); }); QTimer heartbeat; heartbeat.setInterval(10); connect(&heartbeat, &QTimer::timeout, this, [&] { const auto now = clock.elapsed(); maxHeartbeatGap = qMax(maxHeartbeatGap, now - lastHeartbeat); lastHeartbeat = now; ++heartbeats; }); heartbeat.start(); pdf.request("render", {}, [&](const QCborMap &) { ++terminalReplies; }); pdf.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); archive.request("extract", {}, [&](const QCborMap &value) { if (value.value("result").toMap().value("more").toBool()) ++chunks; else ++terminalReplies; }); archive.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); cancelled.request("render", {}, [&](const QCborMap &) { ++terminalReplies; }); cancelled.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); QTimer::singleShot(200, &cancelled, [&] { QElapsedTimer stopClock; stopClock.start(); cancelled.stop(); cancelDuration = stopClock.elapsed(); }); QTRY_VERIFY_WITH_TIMEOUT(pdfStopped >= 0 && archiveStopped >= 0, 125000); QTest::qWait(100); heartbeat.stop(); QCOMPARE(pdfFailures.size(), 1); QCOMPARE(archiveFailures.size(), 1); QCOMPARE(cancelFailures.size(), 0); QCOMPARE(pdfFailures[0][0].toString(), "E_WORKER_TIMEOUT"); QCOMPARE(archiveFailures[0][0].toString(), "E_WORKER_TIMEOUT"); QCOMPARE(pdfSlowCount, 1); QCOMPARE(archiveSlowCount, 1); QVERIFY2(pdfSlow >= 30000 && pdfSlow < 34000, qPrintable(QString::number(pdfSlow))); QVERIFY2(archiveSlow >= 10000 && archiveSlow < 14000, qPrintable(QString::number(archiveSlow))); QVERIFY2(pdfStopped >= 120000 && pdfStopped < 124000, qPrintable(QString::number(pdfStopped))); QVERIFY2(archiveStopped >= 30000 && archiveStopped < 34000, qPrintable(QString::number(archiveStopped))); QVERIFY(cancelDuration >= 0 && cancelDuration < 1000); QVERIFY(heartbeats > 5000); QVERIFY2(maxHeartbeatGap < 1500, qPrintable(QString::number(maxHeartbeatGap))); QVERIFY(chunks >= 20); QCOMPARE(terminalReplies, 0); QVERIFY(!pdf.isConnected() && !archive.isConnected() && !cancelled.isConnected()); for (const auto pid : {pdfPid, archivePid, cancelPid}) { QVERIFY(pid > 0); QVERIFY(!QFileInfo::exists(QStringLiteral("/proc/%1").arg(pid))); } const QJsonObject report{ {"success", true}, {"platform", "Linux; sandboxed stalled IPC fixture; production WorkerProcess timers"}, {"testExecutableSha256", digest(QCoreApplication::applicationFilePath())}, {"commonLibrarySha256", digest(QCoreApplication::applicationDirPath() + "/libdocview_common.a")}, {"pdfSlowMs", pdfSlow}, {"pdfStopMs", pdfStopped}, {"archiveSlowMs", archiveSlow}, {"archiveStopMs", archiveStopped}, {"cancelDurationMs", cancelDuration}, {"heartbeatCount", heartbeats}, {"maxHeartbeatGapMs", maxHeartbeatGap}, {"archiveProgressChunks", chunks}, {"queuedOrTerminalReplies", terminalReplies}, {"childrenReaped", true}, {"unrelatedUserDocuments", "none"}}; qInfo().noquote() << QJsonDocument(report).toJson(QJsonDocument::Compact); const auto reportPath = qEnvironmentVariable("DOCVIEW_WATCHDOG_REPORT"); if (!reportPath.isEmpty()) { QSaveFile output(reportPath); QVERIFY(output.open(QIODevice::WriteOnly)); const auto data = QJsonDocument(report).toJson(); QCOMPARE(output.write(data), data.size()); QVERIFY(output.commit()); } } }; int main(int argc, char **argv) { QCoreApplication app(argc, argv); if (app.arguments().contains("--stalled-peer")) return stalledPeer(app); WorkerDeadlinesTest test; return QTest::qExec(&test, argc, argv); } #include "test_worker_deadlines.moc"