feat: measure why lessons load slowly, and give a technician the same tool

Measured before changing anything, and nothing about loading changed,
because the evidence does not point at Wonder Ink. Same lab, same page,
timed by each engine until the sign-in form exists: Wonder Ink 2.86-3.00 s
cold and 2.20-2.49 s warm, Chrome 2.79-3.48 s and 1.96-2.15 s. The site is
the weight — 119-160 requests and 3.2 MB for a sign-in form, and its
console reports loading the Google Maps API twice. Memory about 610 MB
for one tab, 115 MB per further tab; idle CPU about 1%.

The lab draws in software for both engines, so it cannot show the one
likely cause it has no hardware for: QtWebEngine failing to use a
classroom's GPU where Chrome succeeds. classroom-wonderink --diagnose,
run from the maintenance terminal in the teacher's session, prints the
engine versions, the scale, the WebGL renderer (a driver, or software),
and the sign-in page's time from a cold cache, in a throwaway profile
beside any running Wonder Ink.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 19:23:20 -05:00
co-authored by Claude Opus 5.5
parent 1cb9fd35d2
commit 88ea3e86fd
3 changed files with 162 additions and 1 deletions
+44 -1
View File
@@ -338,7 +338,50 @@ The lab's injected keyboard does not reach its nested session, so the
check delivers keys through Qt's own key path to the focused page; a check delivers keys through Qt's own key path to the focused page; a
physical Ctrl+Plus on a classroom keyboard is a hardware check. physical Ctrl+Plus on a classroom keyboard is a hardware check.
## Turning it off ## Why lessons load slowly
Measured before anything was changed, and nothing about loading was
changed, because the evidence does not point at Wonder Ink.
In the lab, on the same connection, the same page, timed by each
engine's own Navigation Timing until the sign-in form exists (the site
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
not the moment a teacher can use it):
| Until the sign-in page is usable | Wonder Ink | Chrome |
| --- | --- | --- |
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
| window on screen after launch | 0.33 s | 0.42 s |
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
form, about 430 KB warm. Its console says it loads the Google Maps
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
* Wonder Ink is about a quarter of a second slower warm: it revalidates
the page shell (62 – 87 ms to first byte) where Chrome serves it from
its cache. Not the "very long time" teachers describe.
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
spins in the background.
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
What the lab **cannot** show is the machines: it draws in software for
both engines. A classroom whose graphics fail in QtWebEngine but work in
Chrome would look exactly like the complaint. So there is a command for a
technician, run from the maintenance terminal inside the teacher's
session (Meta+Ctrl+Alt+Shift+K):
classroom-wonderink --diagnose
It prints the engine's versions, the scale, **what WebGL reports as the
renderer** — a real driver's name, or "none … drawing in software" — and
times the sign-in page from a cold cache in a throwaway profile, beside a
running Wonder Ink without touching it. Compare its time with the lab's
2.9 s, and its renderer with Chrome's on the same machine (open
`chrome://gpu` there).
One future device is a laptop that will not have Wonder Ink at all. One future device is a laptop that will not have Wonder Ink at all.
+111
View File
@@ -1693,9 +1693,120 @@ def build_profile(app, name="wonderink"):
return profile return profile
# What --diagnose asks a page. The renderer name WebGL reports is the
# plainest answer to "is this engine drawing with the GPU": a real driver's
# name, or llvmpipe / SwiftShader / nothing when it is drawing in software.
# chrome://gpu would say more and draws nothing in this engine build.
DIAGNOSE_GPU = """(function(){
var g = document.createElement('canvas').getContext('webgl');
if (!g) return 'none: no WebGL, so the engine is drawing in software';
var e = g.getExtension('WEBGL_debug_renderer_info');
return e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER);
})()"""
DIAGNOSE_LOAD = """(function(){
var n = performance.getEntriesByType('navigation')[0] || {};
var r = performance.getEntriesByType('resource'), bytes = n.transferSize || 0;
r.forEach(function(x){ bytes += x.transferSize || 0; });
return JSON.stringify({ready: !!document.querySelector('input[type=email], input[type=password]'),
first_byte: Math.round(n.responseStart || 0), requests: r.length + 1,
kilobytes: Math.round(bytes / 1024)});
})()"""
def diagnose(config):
"""For a technician at a classroom: why is a lesson slow here?
Run from the maintenance terminal, inside the teacher's session, so
that it sees that session's display and graphics. Opens nothing a
teacher could use, in a throwaway profile, so it can run beside a
signed-in Wonder Ink without touching it, and times the same thing
tools/lab measured: the sign-in page from a cold cache.
"""
import json
import time
from PyQt6.QtCore import PYQT_VERSION_STR, QT_VERSION_STR
from PyQt6.QtWebEngineCore import qWebEngineChromiumVersion, qWebEngineVersion
started = time.monotonic()
app = QApplication(sys.argv[:1])
# Off the record, and OWNED: a profile nobody holds is deleted at
# once while its page still uses it — measured, a segfault, the same
# shape of mistake as the 0.2.2 printing crash.
profile = QWebEngineProfile(app)
view = QWebEngineView()
page = QWebEnginePage(profile, view)
view.setPage(page)
view.resize(1280, 800)
view.show()
room = ui_scale(config)
print("Wonder Ink diagnostics")
print(f" Qt {QT_VERSION_STR}, PyQt {PYQT_VERSION_STR}, QtWebEngine "
f"{qWebEngineVersion()} (Chromium {qWebEngineChromiumVersion()})")
print(f" platform {QGuiApplication.platformName()}, window scale "
f"{view.devicePixelRatioF():g}, UI_SCALE {room:g}, lessons open at "
f"{room * load_level():g}x zoom")
result = {}
def finish(code):
result["code"] = code
app.quit()
def gpu(renderer):
print(f" GPU (WebGL renderer): {renderer}")
view.setZoomFactor(1.0)
view.load(QUrl(HOME_PAGE))
poll()
def poll():
if time.monotonic() - started > 60:
print(" sign-in page: NOT READY after 60 s")
return finish(1)
view.page().runJavaScript(DIAGNOSE_LOAD, measured)
def measured(answer):
found = json.loads(answer) if answer else {}
if not found.get("ready"):
return QTimer.singleShot(100, poll)
print(f" sign-in page usable after {time.monotonic() - loading[0]:.2f} s, "
f"first byte {found['first_byte']} ms, {found['requests']} requests, "
f"{found['kilobytes']} KB")
print(" (the lab, on a wired connection: about 2.9 s cold, "
"130-160 requests, 3.2 MB)")
finish(0)
loading = [time.monotonic()]
view.loadStarted.connect(lambda: loading.__setitem__(0, time.monotonic()))
view.setHtml("<p>diagnostics</p>", QUrl("https://diagnose.invalid/"))
view.loadFinished.connect(
lambda ok: view.page().runJavaScript(DIAGNOSE_GPU, gpu)
if view.url().host() == "diagnose.invalid" else None
)
QTimer.singleShot(90_000, lambda: finish(1))
app.exec()
# The page goes before its profile, or Qt warns and may crash on the
# way out.
view.close()
page.deleteLater()
view.deleteLater()
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
sys.stdout.flush()
return result.get("code", 1)
def main(): def main():
config = read_device_config() config = read_device_config()
if "--diagnose" in sys.argv[1:]:
return diagnose(config)
# A device that has no Wonder Ink has no Wonder Ink. The panel does not # A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the # offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why. # technician who ran the command by hand and wants to know why.
+7
View File
@@ -313,6 +313,13 @@ check("Wonder Ink is not told twice",
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice # Its PAGES are, because Chrome under Plasma turned out to be scaled twice
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers # (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
# read it at in the browser. The window itself is left to Qt. # read it at in the browser. The window itself is left to Qt.
# A technician's command, never a teacher's: it runs in a throwaway
# profile that something owns (an unowned one crashed it), and it never
# touches the signed-in profile a teacher may have open beside it.
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
check("--diagnose uses an owned, throwaway profile",
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
and '"--diagnose" in sys.argv' in wonderink_code)
check("its pages start at the room's scale, which is the browser's size", check("its pages start at the room's scale, which is the browser's size",
"room_zoom=ui_scale(config)" in wonderink_code "room_zoom=ui_scale(config)" in wonderink_code
and "self._room_zoom * self._level" in wonderink_code) and "self._room_zoom * self._level" in wonderink_code)