#pragma once #include "app/controller.h" #include "app/pdf_canvas.h" #include #include #include #include #include #include #include // Test-only instrumentation. It observes the production event loop, presentation // notifications and worker counters without changing scheduling or deadlines. class InteractionMeasurement : public QObject { public: static qint64 nowNs() { return std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(); } InteractionMeasurement(docview::Controller *reader, docview::PdfCanvas *canvas, QQuickWindow *window) : reader_(reader), canvas_(canvas), start_(nowNs()) { connect(window, &QQuickWindow::frameSwapped, this, [this] { const auto presented = nowNs(); QMetaObject::invokeMethod(this, [this, presented] { ++frameSerial; lastFrameNs = presented; if (phase_.isEmpty() || presented < phaseStart_) return; frames.append(QJsonObject{{"phase", phase_}, {"atMs", ms(presented)}, {"intervalMs", previousFrame_ ? (presented - previousFrame_) / 1e6 : 0.}}); previousFrame_ = presented; sample(); }, Qt::QueuedConnection); }, Qt::DirectConnection); connect(reader, &docview::Controller::disposeWeb, this, [this] { sample(); }); connect(canvas, &docview::PdfCanvas::tileRequested, this, [this] { sample(); }); timer_.setInterval(10); timer_.setTimerType(Qt::PreciseTimer); connect(&timer_, &QTimer::timeout, this, [this] { const auto now = nowNs(); if (!phase_.isEmpty()) { if (previousTick_) heartbeats.append(QJsonObject{{"phase", phase_}, {"atMs", ms(now)}, {"gapMs", (now - previousTick_) / 1e6}}); previousTick_ = now; sample(); } }); timer_.start(); } double ms(qint64 time) const { return (time - start_) / 1e6; } void begin(const QString &phase) { phase_ = phase; previousFrame_ = previousTick_ = 0; phaseStart_ = nowNs(); phaseInput_ = phaseStart_; sample(); } void input(QJsonObject event) { phaseInput_ = nowNs(); event["atMs"] = ms(phaseInput_); event["phase"] = phase_; inputs.append(event); sample(); } void inputOutcome(const QJsonObject &outcome) { auto row = inputs.last().toObject(); for (auto it = outcome.begin(); it != outcome.end(); ++it) row[it.key()] = it.value(); inputs[inputs.size() - 1] = row; } void finish(const QJsonObject &verified) { QJsonObject row = verified; row["phase"] = phase_; row["startMs"] = ms(phaseStart_); row["lastInputMs"] = ms(phaseInput_); row["finalPresentedMs"] = ms(lastFrameNs); row["lastInputToFinalFrameMs"] = (lastFrameNs - phaseInput_) / 1e6; row["phaseDurationMs"] = (lastFrameNs - phaseStart_) / 1e6; phases.append(row); sample(); phase_.clear(); } void sample() { qint64 queued = 0, active = 0, queuedRenders = 0, activeRenders = 0; for (auto *object : reader_->children()) { auto *session = dynamic_cast(object); if (!session || !session->worker) continue; auto *worker = session->worker; const auto discarded = qint64(worker->discardedQueuedRequestCount()); discarded_ += qMax(0, discarded - observedDiscards_.value(session->token)); observedDiscards_[session->token] = discarded; queued += worker->queuedRequestCount(); active += worker->activeRequestCount(); queuedRenders += worker->queuedRequestCount("render"); activeRenders += worker->activeRequestCount("render"); } const auto pending = qint64(canvas_->pendingRenderCount()); pendingPeak_ = qMax(pendingPeak_, pending); queuePeak_ = qMax(queuePeak_, queued); queuedRenderPeak_ = qMax(queuedRenderPeak_, queuedRenders); activeRenderPeak_ = qMax(activeRenderPeak_, activeRenders); cachePeak_ = qMax(cachePeak_, canvas_->cacheCostBytes()); if (!phase_.isEmpty() && samples.size() < 100000) samples.append(QJsonObject{{"phase", phase_}, {"atMs", ms(nowNs())}, {"queuedRequests", queued}, {"activeRequests", active}, {"queuedRenders", queuedRenders}, {"activeRenders", activeRenders}, {"pendingCanvasRenders", pending}, {"discardedQueuedRequestsTotal", discarded_}, {"staleRenderResponses", qint64(canvas_->staleRenderResponseCount())}, {"discardedPrefetchResponses", qint64(canvas_->discardedPrefetchResponseCount())}, {"cacheBytes", canvas_->cacheCostBytes()}}); } static QJsonObject statistics(QList values) { if (values.isEmpty()) return {{"count", 0}}; std::sort(values.begin(), values.end()); auto percentile = [&](double p) { return values[qBound(0, qsizetype(std::ceil(p * values.size())) - 1, values.size() - 1)]; }; return {{"count", values.size()}, {"minMs", values.first()}, {"p50Ms", percentile(.5)}, {"p95Ms", percentile(.95)}, {"maxMs", values.last()}}; } QJsonObject result(bool success) { sample(); QList intervals, gaps; for (const auto &row : frames) if (row.toObject().value("intervalMs").toDouble() > 0) intervals.append(row.toObject().value("intervalMs").toDouble()); for (const auto &row : heartbeats) gaps.append(row.toObject().value("gapMs").toDouble()); const auto frameStats = statistics(intervals), heartbeatStats = statistics(gaps); return {{"schemaVersion", 1}, {"success", success}, {"designRequirement", "08-performance-tests ยง3.3(5), T20"}, {"scope", "Synthetic bounded interaction workload; development-host reference, not reference-machine acceptance"}, {"presentationBoundary", "second frameSwapped after target and viewport readiness; final PDF also pixel-checked. frameSwapped is presentation-queue notification, not physical scanout; Web frames are not correlated with Chromium frame IDs"}, {"uiGapDefinition", "actual intervals of a 10 ms precise GUI timer; a proxy for event-loop starvation, not OS scheduling attribution"}, {"discardDefinition", "actual WorkerProcess lifetime unsent-queue discards, including stop sampled before Session deletion; excludes active cancellation"}, {"inputs", inputs}, {"phases", phases}, {"frames", frames}, {"uiHeartbeats", heartbeats}, {"workSamples", samples}, {"frameIntervals", frameStats}, {"uiHeartbeatGaps", heartbeatStats}, {"maxUiDelayBeyondNominalMs", qMax(0., heartbeatStats.value("maxMs").toDouble() - 10.)}, {"discardedQueuedRequests", discarded_}, {"pendingRenderPeak", pendingPeak_}, {"queuedRequestPeak", queuePeak_}, {"queuedRenderPeak", queuedRenderPeak_}, {"activeRenderPeak", activeRenderPeak_}, {"cachePeakBytes", cachePeak_}, {"provisionalBudgetsUnchanged", QJsonObject{{"visualResponseP95Ms", 50}, {"cachedPdfP95Ms", 100}, {"uncachedPdfP95Ms", 500}, {"continuousFrameP95MsAt60Hz", 33}}}, {"budgetAssessment", "not evaluated as a product guarantee: operation burst/final-frame latencies differ from per-input visual-response metric"}}; } quint64 frameSerial = 0; qint64 lastFrameNs = 0; QJsonArray inputs, phases, frames, heartbeats, samples; private: docview::Controller *reader_; docview::PdfCanvas *canvas_; QTimer timer_; QString phase_; QHash observedDiscards_; qint64 start_, phaseStart_ = 0, phaseInput_ = 0, previousFrame_ = 0, previousTick_ = 0; qint64 discarded_ = 0, pendingPeak_ = 0, queuePeak_ = 0, queuedRenderPeak_ = 0, activeRenderPeak_ = 0, cachePeak_ = 0; };