diff --git a/docs/wonder-ink.md b/docs/wonder-ink.md index 99e6752..b89121c 100644 --- a/docs/wonder-ink.md +++ b/docs/wonder-ink.md @@ -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 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. diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 701be5e..7074998 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -1693,9 +1693,120 @@ def build_profile(app, name="wonderink"): 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("
diagnostics
", 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(): 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 # 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. diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 53463a9..7a62068 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -313,6 +313,13 @@ check("Wonder Ink is not told 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 # 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", "room_zoom=ui_scale(config)" in wonderink_code and "self._room_zoom * self._level" in wonderink_code)