Skip to content

feat(runtime): opt-in Qt event loop integration via qasync - #10584

Draft
larsoner wants to merge 1 commit into
marimo-team:mainfrom
larsoner:gui-event-loop
Draft

feat(runtime): opt-in Qt event loop integration via qasync#10584
larsoner wants to merge 1 commit into
marimo-team:mainfrom
larsoner:gui-event-loop

Conversation

@larsoner

Copy link
Copy Markdown

This pull request was authored by a coding agent. (Claude Code, driven by @larsoner)

📝 Summary

WIP exploration of GUI event loop integration (#1986), sized so you can see what real support would take. Adds an opt-in config:

[tool.marimo.runtime]
gui_event_loop = "qt"

With it set, the kernel's asyncio loop is created by a factory returning a qasync QEventLoop instead of a plain selector loop, so the same loop drives both asyncio and Qt. Qt windows opened from cells (napari, MNE-Python's raw.plot(), plain QWidgets) then stay responsive whenever the kernel is idle — no library-side processEvents() polling task, no added latency, no CPU burn.

This came out of mne-tools/mne-qt-browser#448: every Qt-based library currently hand-rolls the same asyncio pump to survive inside marimo (napari users in this issue's thread, MNE, others). A pump caps GUI responsiveness at its polling interval and burns CPU while idle; sharing the loop is the architecturally right integration, and marimo already had the seam for it (launch_kernel's loop_factory, previously used only for Windows' proactor loop).

What's in the diff

  • runtime.gui_event_loop config key ("qt" | absent). Backend-only; the frontend config schema is a looseObject so no frontend change is needed for marimo.toml/pyproject.toml users. Not exposed in the settings UI yet — happy to add if you want this at all.
  • marimo/_runtime/gui_loop.py: resolves a Qt binding (already-imported → QT_API env var → probe), then builds a factory that creates the QApplication and wraps it in a qasync.QEventLoop. Two hazards found during real testing are handled here:
    • qasync's own binding detection probes import PyQt5 bare, which false-positives on partial installs (a stray PyQt5-Qt5 wheel leaves an importable namespace package without QtCore). We import the binding (verified via QtCore) first, which pins qasync's sys.modules-based detection — and we un-import partial packages our probe pulled in, or qasync would trip over them.
    • app.setQuitOnLastWindowClosed(False): the Qt loop is the kernel's loop, so a user closing their last window must not stop the kernel.
  • launch_kernel wiring: misconfiguration or missing deps logs an error and falls back to the default loop — the kernel always starts. Ignored (with a warning) in run mode, where the kernel doesn't own the process's main thread that GUI toolkits require.
  • _asyncio_run helper: asyncio.run(loop_factory=...) is 3.12+; 3.11 uses asyncio.Runner, 3.10 gets a small manual equivalent.
  • qasync is optional (DependencyManager), not a dependency of marimo.
  • Unit tests (fake binding + fake qasync, no Qt needed in CI), docs section, regenerated OpenAPI schema/types.

Verified end-to-end (Linux, PySide6 6.11.1, real X11/Wayland display)

Measured with a 100 ms QTimer tick counter inside a real marimo edit kernel (50 ticks/5 s = fully responsive):

scenario default loop gui_event_loop = "qt"
plain QLabel, no pump anywhere 0 / 5 s (frozen) 50 / 5 s
MNE-Python raw.plot() (its own pump disabled) 0 47–52 / 5 s
user closes the last open window kernel keeps running
kernel loop class _UnixSelectorEventLoop qasync.QSelectorEventLoop

Known caveats (documented, not solved here)

  • Cells still block repaints. The loop can only service Qt between cell runs; a long synchronous cell (or reactive cascade) freezes windows for its duration, same as Jupyter's %gui qt. The docs section points users at gating downstream cells on mo.ui.run_button.
  • app.exec() inside a cell is incompatible with a shared loop (Qt warns "the event loop is already running" and returns immediately), and library code that connects lastWindowClosed → app.quit() (e.g. MNE's blocking helper) would stop the kernel's loop. This needs library-side cooperation; mne-qt-browser now skips its pump when it detects a qasync loop (mne-tools/mne-qt-browser@c73f635).
  • Sync code that pumps Qt itself (splash screens calling processEvents()) re-enters asyncio and can log "Cannot enter into task" errors if another task is ready to step. Benign in testing once the redundant library pump was removed.

Open questions for maintainers

  1. Is a config key the right opt-in surface, or would you prefer a runtime API (mo.enable_gui("qt") can't swap an already-running loop, so it would have to be config/CLI anyway)?
  2. Naming: gui_event_loop vs IPython's gui?
  3. Should this eventually surface in the settings UI, or stay a power-user TOML key?
  4. A public "kernel mode / owns-main-thread" detection API would let GUI libraries degrade gracefully instead of sniffing marimo internals — worth a follow-up issue?

Fixes #1986 (partially — Qt only).

…am#1986)

Adds runtime.gui_event_loop = "qt": the kernel's asyncio loop becomes a
qasync QEventLoop, so Qt windows opened from cells (napari, MNE-Python, ...)
stay responsive while the kernel is idle, with no library-side
processEvents() polling. Edit mode only; missing deps fall back to the
default loop with an error logged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview Aug 18, 2026 8:26pm

Request Review

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions github-actions Bot added documentation Improvements or additions to documentation bash-focus Area to focus on during release bug bash labels Aug 18, 2026
@larsoner

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@larsoner

larsoner commented Aug 18, 2026

Copy link
Copy Markdown
Author

Also verified on macOS 26.5.2 (Python 3.14.4, PySide6 6.11.1, qasync 0.28.0), same numbers: kernel loop is qasync.QSelectorEventLoop, a plain QLabel gets 50 ticks/5 s and MNE-Python's raw.plot() 52 ticks/5 s with no pump anywhere, closing the last window leaves the kernel running, and an unknown gui_event_loop value falls back to the default loop.

EDIT: Also, I do have access to a Windows machine if interactive testing is required there, too, at some point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bash-focus Area to focus on during release bug bash documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GUI Event Loop integration

1 participant