Fix 100% CPU on idle pages: block in the GUI run loop, don't busy-spin

ober

eaff6739b911d68614e2f048dc261dc9d1c08df6

diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 801e7c6..2d19c57 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -113,6 +113,11 @@ JWB_API JwbStatus jwb_title(JwbHandle view,
  */
 JWB_API JwbStatus jwb_pump_events(uint32_t max_ms);
 
+/* Block until an event arrives or max_ms elapses (then dispatch the pending
+ * batch). For interactive run loops: unlike jwb_pump_events' fixed time budget,
+ * this sleeps when idle instead of busy-spinning, so it does not peg a core. */
+JWB_API JwbStatus jwb_pump_wait(uint32_t max_ms);
+
 JWB_API JwbStatus jwb_load_url_sync(JwbHandle view,
                                     const uint8_t *url,
                                     uintptr_t len,
diff --git a/qt-webengine/src/browser_page.cpp b/qt-webengine/src/browser_page.cpp
index a4ad8a0..0fbbdcf 100644
--- a/qt-webengine/src/browser_page.cpp
+++ b/qt-webengine/src/browser_page.cpp
@@ -7,6 +7,7 @@
 #include <QElapsedTimer>
 #include <QEventLoop>
 #include <QString>
+#include <QTimer>
 #include <QUrl>
 #include <QVariant>
 #include <QWebEngineFindTextResult>
@@ -72,6 +73,29 @@ JWB_API JwbStatus jwb_pump_events(uint32_t max_ms) {
   return JWB_OK;
 }
 
+// Block until at least one event arrives or `max_ms` elapses, dispatch the
+// pending batch, and return. Unlike jwb_pump_events (a fixed time budget that
+// busy-spins on an idle page: the processEvents(flags, maxtime) overload
+// IGNORES WaitForMoreEvents and returns instantly when the queue is empty),
+// this calls the no-maxtime overload that actually honors WaitForMoreEvents, so
+// an interactive run loop sleeps in the OS when idle (~0% CPU) yet wakes
+// immediately on a key, paint, network reply, or window close. The one-shot
+// heartbeat guarantees we still return at least every max_ms so the caller can
+// re-check its quit/visibility flags even when no events are flowing.
+JWB_API JwbStatus jwb_pump_wait(uint32_t max_ms) {
+  jwb::clear_last_error();
+  if (!jwb::qapp()) {
+    jwb::set_last_error("runtime not initialized; call jwb_runtime_init first");
+    return JWB_ERR_QT;
+  }
+  QTimer heartbeat;
+  heartbeat.setSingleShot(true);
+  heartbeat.start(max_ms > 0 ? static_cast<int>(max_ms) : 1);
+  QCoreApplication::processEvents(QEventLoop::WaitForMoreEvents |
+                                  QEventLoop::AllEvents);
+  return JWB_OK;
+}
+
 JWB_API JwbStatus jwb_load_url_sync(JwbHandle view, const uint8_t *url,
                                     uintptr_t len, uint32_t timeout_ms) {
   jwb::clear_last_error();
diff --git a/scheme/browser.ss b/scheme/browser.ss
index 97f9035..ef521e6 100644
--- a/scheme/browser.ss
+++ b/scheme/browser.ss
@@ -43,6 +43,7 @@
     browser-focus
     browser-set-title
     browser-pump
+    browser-pump-wait
     browser-exec
     browser-quit
     browser-close-view
@@ -183,6 +184,7 @@
   (define-c-lambda %focus        (uint64)                    int         "jwb_view_focus")
   (define-c-lambda %set-title    (uint64 char-string)        int         "jwb_view_set_title")
   (define-c-lambda %pump         (uint32)                    int         "jwb_pump_events")
+  (define-c-lambda %pump-wait    (uint32)                    int         "jwb_pump_wait")
   (define-c-lambda %exec         ()                          int         "jwb_exec")
   (define-c-lambda %quit         ()                          int         "jwb_quit")
   ;; automation / testing hooks (browser_automation.cpp)
@@ -353,6 +355,7 @@
   ;; Drive the shared Qt event loop for up to ms milliseconds (non-blocking
   ;; cooperative pump for the REPL between operations).
   (def (browser-pump ms) (status-result "pump" (%pump ms)))
+  (def (browser-pump-wait ms) (status-result "pump-wait" (%pump-wait ms)))
 
   ;; Run the shared Qt event loop until browser-quit (blocking GUI session).
   (def (browser-exec) (status-result "exec" (%exec)))
diff --git a/scheme/browser/commands.ss b/scheme/browser/commands.ss
index 0150fc4..d10a2a1 100644
--- a/scheme/browser/commands.ss
+++ b/scheme/browser/commands.ss
@@ -208,17 +208,20 @@
               (when tok (app-feed-token! app tok)))
             (loop))))))
 
-  ;; The interactive GUI loop: install key capture, then pump Qt events + drain
-  ;; keys until the quit command fires or the user closes the window. Replaces a
-  ;; bare browser-exec so keystrokes route through the keymap. `tick-ms` is the
-  ;; per-iteration Qt pump budget (default ~60fps).
+  ;; The interactive GUI loop: install key capture, then block on Qt events +
+  ;; drain keys until the quit command fires or the user closes the window.
+  ;; Replaces a bare browser-exec so keystrokes route through the keymap.
+  ;; browser-pump-wait sleeps until an event arrives (a key, paint, reply, or
+  ;; window close), so an idle page costs ~0% CPU instead of busy-spinning a
+  ;; core; `tick-ms` is only the idle-heartbeat cap — we still wake at least
+  ;; that often to re-check the quit/visibility flags when no events flow.
   (def (app-run-loop! app . opt)
     (let ((win (session-window (app-session app)))
-          (tick-ms (if (pair? opt) (car opt) 16)))
+          (tick-ms (if (pair? opt) (car opt) 250)))
       (browser-window-install-key-hook win)
       (app-render! app)
       (let loop ()
-        (browser-pump tick-ms)
+        (browser-pump-wait tick-ms)
         (app-pump-keys! app)
         (unless (or (app-should-quit? app) (not (browser-window-visible? win)))
           (loop)))