Files
docview/tests/test_worker_deadlines.cpp
T
2026-09-21 13:41:40 +09:00

208 lines
9.0 KiB
C++

#include "common/ipc.h"
#include "common/sandbox.h"
#include "common/worker_process.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalSocket>
#include <QSaveFile>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTimer>
#include <QtTest>
#include <cstdlib>
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"