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

253 lines
14 KiB
C++

#include "broker/font_broker.h"
#include "common/font_contract.h"
#include <QCryptographicHash>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QFile>
#include <QPointer>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
#include <QThread>
#include <QTimer>
#include <QTest>
#include <cstdio>
#ifdef Q_OS_LINUX
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
using namespace docview;
namespace {
QCborMap mapRequest(QByteArray family = "Helvetica", int weight = 400) {
return {{"faceLocal", family}, {"weight", weight}, {"italic", false}, {"charset", 0}, {"pitchFamily", 0}};
}
QCborMap call(FontBroker &broker, const QString &operation, const QCborMap &payload) {
struct Result { QCborMap map; bool complete = false; QPointer<QEventLoop> loop; };
auto result = std::make_shared<Result>();
QEventLoop loop; result->loop = &loop;
QTimer timer; timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
broker.request(operation, payload, [result](QCborMap map) {
result->map = std::move(map); result->complete = true;
if (result->loop) result->loop->quit();
});
timer.start(10000);
if (!result->complete) loop.exec();
if (!result->complete) return {{"testTimeout", true}};
return result->map;
}
QString code(const QCborMap &result) { return result.value("error").toMap().value("code").toString(); }
}
class FontBrokerTest : public QObject {
Q_OBJECT
private slots:
void cleanup() { QTest::qWait(10); } // Let revoked bounded OS threads release their permits.
void asynchronousMappingAndSelectedBytes() {
FontBroker broker;
bool callback = false, returned = false; QCborMap mapped;
broker.request("font.map", mapRequest(), [&](QCborMap result) {
QVERIFY(returned); QCOMPARE(QThread::currentThread(), broker.thread());
callback = true; mapped = std::move(result);
});
returned = true; QVERIFY(!callback);
QTRY_VERIFY_WITH_TIMEOUT(callback, 10000);
QVERIFY2(mapped.value("found").toBool(), qPrintable(QCborValue(mapped).toDiagnosticNotation()));
QVERIFY(validFontId(mapped.value("fontId")));
QVERIFY(!mapped.contains(QStringLiteral("path"))); QVERIFY(!mapped.contains(QStringLiteral("fontRequestId")));
const auto id = mapped.value("fontId"); const auto size = mapped.value("size").toInteger();
QVERIFY(size > 0 && size <= MaxFontSnapshotBytes);
QCryptographicHash hash(QCryptographicHash::Sha256);
for (qint64 offset = 0; offset < size; offset += MaxFontReadBytes) {
const auto length = qMin<qint64>(MaxFontReadBytes, size - offset);
const auto result = call(broker, "font.read", {{"fontId", id}, {"offset", offset}, {"length", length}});
QCOMPARE(result.value("data").toByteArray().size(), length); QCOMPARE(result.value("offset").toInteger(), offset);
hash.addData(result.value("data").toByteArray());
}
QCOMPARE(hash.result(), mapped.value("sha256").toByteArray());
QVERIFY(call(broker, "font.close", {{"fontId", id}}).value("released").toBool());
QCOMPARE(code(call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_FAILED");
QCOMPARE(code(call(broker, "font.close", {{"fontId", id}})), "E_FONT_FAILED");
}
void namesAreValuesAndReferencesArePrivate() {
QTemporaryDir directory; QVERIFY(directory.isValid());
QFile secret(directory.filePath("private.ttf")); QVERIFY(secret.open(QIODevice::WriteOnly));
const QByteArray canary("NOT AN INSTALLED FONT: PRIVATE FIXTURE"); secret.write(canary); secret.close();
FontBroker first, second;
for (const auto &name : {secret.fileName().toLocal8Bit(), QByteArray("sans:file=") + secret.fileName().toLocal8Bit()}) {
const auto result = call(first, "font.map", mapRequest(name));
QVERIFY(!result.contains(QStringLiteral("error")));
if (result.value("found").toBool()) {
QVERIFY(result.value("sha256").toByteArray() != QCryptographicHash::hash(canary, QCryptographicHash::Sha256));
const auto id = result.value("fontId");
QCOMPARE(code(call(second, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_FAILED");
QVERIFY(call(first, "font.close", {{"fontId", id}}).value("released").toBool());
}
}
}
void malformedInputAndReadBounds() {
FontBroker broker;
auto invalid = mapRequest(); invalid.insert(QStringLiteral("path"), "/etc/passwd");
QCOMPARE(code(call(broker, "font.map", invalid)), "E_FONT_FAILED");
QCOMPARE(code(call(broker, "font.map", mapRequest(QByteArray(257, 'a')))), "E_FONT_FAILED");
QCOMPARE(code(call(broker, "font.map", mapRequest(QByteArray("a\0b", 3)))), "E_FONT_FAILED");
invalid = mapRequest(); invalid.insert(QStringLiteral("weight"), 400.5);
QCOMPARE(code(call(broker, "font.map", invalid)), "E_FONT_FAILED");
const auto font = call(broker, "font.map", mapRequest()); QVERIFY(font.value("found").toBool());
const auto id = font.value("fontId"); const auto size = font.value("size").toInteger();
for (const auto &request : {QCborMap{{"fontId", id}, {"offset", -1}, {"length", 1}},
QCborMap{{"fontId", id}, {"offset", 0}, {"length", 65537}},
QCborMap{{"fontId", id}, {"offset", size}, {"length", 1}}})
QCOMPARE(code(call(broker, "font.read", request)), "E_FONT_FAILED");
}
void standardAliasesPreserveResolvedFamilyAndStyle() {
FontBroker broker;
for (const auto &family : {QByteArray("Helvetica"), QByteArray("Times-Roman"), QByteArray("Courier")}) {
const auto regular = call(broker, "font.map", mapRequest(family));
QVERIFY(regular.value("found").toBool());
const auto actual = regular.value("actualFaceLocal").toByteArray();
QVERIFY(!actual.isEmpty());
const auto exact = call(broker, "font.map", mapRequest(actual));
QVERIFY(exact.value("found").toBool()); QVERIFY(!exact.value("substituted").toBool());
QCOMPARE(exact.value("sha256"), regular.value("sha256"));
auto boldRequest = mapRequest(family, 700); boldRequest.insert(QStringLiteral("italic"), true);
const auto bold = call(broker, "font.map", boldRequest);
QVERIFY(bold.value("found").toBool());
QCOMPARE(bold.value("actualFaceLocal"), regular.value("actualFaceLocal"));
// Standard OS alias families in the supported test environments
// provide a distinct bold-italic face, rather than fake metadata.
QVERIFY(bold.value("sha256") != regular.value("sha256"));
for (const auto &font : {regular, exact, bold})
QVERIFY(call(broker, "font.close", {{"fontId", font.value("fontId")}}).value("released").toBool());
}
}
void opaqueHandleQuotaAndReuse() {
FontBroker broker; QList<QCborValue> ids;
for (int i = 0; i < 16; ++i) {
const auto result = call(broker, "font.map", mapRequest()); QVERIFY(result.value("found").toBool());
const auto id = result.value("fontId"); QVERIFY(!ids.contains(id)); ids << id;
}
QCOMPARE(code(call(broker, "font.map", mapRequest())), "E_FONT_LIMIT");
QVERIFY(call(broker, "font.close", {{"fontId", ids.takeLast()}}).value("released").toBool());
QVERIFY(call(broker, "font.map", mapRequest()).value("found").toBool());
}
void distinctSelectionQuotaIncludesAbsentFonts() {
FontBroker broker;
auto request = mapRequest(); request.insert(QStringLiteral("charset"), 2);
for (int i = 0; i < 256; ++i) {
request.insert(QStringLiteral("faceLocal"), QByteArray("DocViewAbsentSymbol-") + QByteArray::number(i));
const auto result = call(broker, "font.map", request);
QVERIFY2(!result.contains(QStringLiteral("error")), qPrintable(code(result)));
if (result.value("found").toBool()) call(broker, "font.close", {{"fontId", result.value("fontId")}});
}
request.insert(QStringLiteral("faceLocal"), QByteArray("DocViewAbsentSymbol-over-limit"));
QCOMPARE(code(call(broker, "font.map", request)), "E_FONT_LIMIT");
}
void transferredBytesAreChargedEvenForRepeatedReads() {
FontBroker broker;
const auto font = call(broker, "font.map", mapRequest()); QVERIFY(font.value("found").toBool());
const auto id = font.value("fontId");
const auto length = qMin<qint64>(MaxFontReadBytes, font.value("size").toInteger());
qint64 read = 0;
while (read < MaxFontTransferBytes) {
const auto count = qMin(length, MaxFontTransferBytes - read);
const auto result = call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", count}});
QCOMPARE(result.value("data").toByteArray().size(), count); read += count;
}
QCOMPARE(code(call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_LIMIT");
}
void revokeDropsQueuedCompletionsWithoutWaiting() {
bool called = false;
auto broker = std::make_unique<FontBroker>();
broker->request("font.map", mapRequest(), [&](QCborMap) { called = true; });
QElapsedTimer elapsed; elapsed.start(); broker->revoke(); broker.reset();
QVERIFY(elapsed.elapsed() < 500);
QTest::qWait(100); QVERIFY(!called);
}
void processWideOsThreadLimit() {
FontBroker first, second, third;
QCOMPARE(code(call(third, "font.map", mapRequest())), "E_FONT_LIMIT");
QVERIFY(call(first, "font.map", mapRequest()).value("found").toBool());
QVERIFY(call(second, "font.map", mapRequest()).value("found").toBool());
}
void globalCacheEvictsOtherServiceButProtectsIssuedHandles() {
qint64 firstSize = 0, secondSize = 0;
{
FontBroker discovery;
const auto regular = call(discovery, "font.map", mapRequest());
const auto bold = call(discovery, "font.map", mapRequest("Helvetica", 700));
QVERIFY(regular.value("found").toBool()); QVERIFY(bold.value("found").toBool());
firstSize = regular.value("size").toInteger(); secondSize = bold.value("size").toInteger();
}
QTest::qWait(10);
const auto limit = qMax(firstSize, secondSize);
QVERIFY(limit > 0 && limit < MaxBrokerFontCacheBytes);
// Both real OS fonts fit individually, but cannot fit together. The
// production cap is only lowered; no fake backend or larger limit.
FontBroker active(nullptr, limit), staging(nullptr, limit);
const auto first = call(active, "font.map", mapRequest()); QVERIFY(first.value("found").toBool());
const auto firstId = first.value("fontId");
QCOMPARE(code(call(staging, "font.map", mapRequest("Helvetica", 700))), "E_FONT_LIMIT");
const auto original = call(active, "font.read", {{"fontId", firstId}, {"offset", 0}, {"length", 16}});
QCOMPARE(original.value("data").toByteArray().size(), 16);
QVERIFY(call(active, "font.close", {{"fontId", firstId}}).value("released").toBool());
// This is the original bug: staging has no local cache victim, and
// must reclaim the unused snapshot still cached by active.
const auto second = call(staging, "font.map", mapRequest("Helvetica", 700));
QVERIFY2(second.value("found").toBool(), qPrintable(QCborValue(second).toDiagnosticNotation()));
const auto secondId = second.value("fontId");
QCOMPARE(code(call(active, "font.map", mapRequest())), "E_FONT_LIMIT");
QCOMPARE(call(staging, "font.read", {{"fontId", secondId}, {"offset", 0}, {"length", 16}}).value("data").toByteArray().size(), 16);
QVERIFY(call(staging, "font.close", {{"fontId", secondId}}).value("released").toBool());
const auto restored = call(active, "font.map", mapRequest()); QVERIFY(restored.value("found").toBool());
QCOMPARE(restored.value("sha256"), first.value("sha256"));
}
#ifdef Q_OS_LINUX
void destroyingFacadeDoesNotWaitForBlockedFontconfig() {
QTemporaryDir directory; QVERIFY(directory.isValid());
const auto fifo = directory.filePath("fontconfig.xml");
const auto path = QFile::encodeName(fifo);
QVERIFY(::mkfifo(path.constData(), 0600) == 0);
QProcess child;
auto environment = QProcessEnvironment::systemEnvironment();
environment.insert("FONTCONFIG_FILE", fifo);
child.setProcessEnvironment(environment);
child.start(QCoreApplication::applicationFilePath(), {"--blocked-fontconfig"});
QVERIFY(child.waitForStarted(5000));
// A writer succeeds only after the backend opens the fixture FIFO for
// reading. Leave it open without bytes so the actual OS font call waits.
int writer = -1; QElapsedTimer wait; wait.start();
while (writer < 0 && wait.elapsed() < 2000 && child.state() != QProcess::NotRunning) {
writer = ::open(path.constData(), O_WRONLY | O_NONBLOCK | O_CLOEXEC);
if (writer < 0) QTest::qWait(2);
}
if (writer < 0) { child.kill(); child.waitForFinished(); }
QVERIFY2(writer >= 0, "The real fontconfig call did not enter the controlled FIFO");
const bool finished = child.waitForFinished(5000);
::close(writer);
if (!finished) { child.kill(); child.waitForFinished(); }
QVERIFY(finished); QCOMPARE(child.exitStatus(), QProcess::NormalExit); QCOMPARE(child.exitCode(), 0);
QCOMPARE(child.readAllStandardOutput().trimmed(), QByteArray("revoked-without-wait"));
}
#endif
};
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
#ifdef Q_OS_LINUX
if (app.arguments().contains("--blocked-fontconfig")) {
auto broker = std::make_unique<FontBroker>();
bool completed = false;
broker->request("font.map", mapRequest(), [&](QCborMap) { completed = true; });
QTimer::singleShot(500, &app, [&] {
QElapsedTimer elapsed; elapsed.start(); broker.reset();
if (elapsed.elapsed() >= 100 || completed) { app.exit(91); return; }
std::puts("revoked-without-wait"); std::fflush(stdout); app.quit();
});
return app.exec();
}
#endif
FontBrokerTest test; return QTest::qExec(&test, argc, argv);
}
#include "test_font_broker.moc"