ex async evals can't load/use GLMakie — GLFW requires thread 1, but Threads.@spawn lands on default pool #21

Closed
opened 2026-04-10 04:39:06 -07:00 by prittjam · 2 comments
prittjam commented 2026-04-10 04:39:06 -07:00 (Migrated from github.com)

Summary

The ex MCP tool routes evals to :eval_async, which uses Threads.@spawn (default pool). GLFW's require_main_thread() then trips on any GLMakie code:

ThreadAssertionError: Code must run on thread 1 but ran on thread N

This affects both module init (using GLMakie) and every plot call (display(fig), screen creation), so plotting is unreachable from ex even after pre-loading GLMakie.

This is a follow-up to #11 with a deterministic repro and root cause.

Repro

julia --startup-file=no --threads=auto,2 --gcthreads=1 -m Kaimon

Then via MCP ex:

using GLMakie  # → ThreadAssertionError at GLFW.SetErrorCallback in GLMakie's __init__

Or, if GLMakie is already loaded:

fig = Figure(); ax = Axis(fig[1,1]); scatter!(ax, [1,2,3], [4,5,6])
display(fig)   # → ThreadAssertionError at GLFW.SetWindowSizeCallback

Threads.threadid() from inside ex confirms it lands on the default pool (e.g. thread 22).

Root cause

gate.jl:1565 uses Threads.@spawn for :eval_async, which schedules onto the default thread pool — never guaranteed to be thread 1. GLFW's main-thread guard (GLFW.jl src/GLFW.jl:26) checks Threads.threadid() == 1 on every callback registration.

Workaround (verified working)

Kaimon's REPL backend task lives on thread 1 and is exposed via Base.active_repl.backendref. REPL.eval_on_backend(ast, backend) sends an AST through the REPL channel, which repl_backend_loop processes via toplevel_eval_with_hooks (proper world-age handling). I define this once per session:

main_eval(code::AbstractString) = REPL.eval_on_backend(
    Meta.parseall(code),
    Base.active_repl.backendref,
)

Then anything thread-1-sensitive is wrapped:

main_eval("using GLMakie; GLMakie.activate!()")
main_eval("display(fig)")
main_eval("include(\"examples/pipeline/fit_homography.jl\")")

Works end-to-end — using GLMakie, figure creation, display(fig), all on thread 1. Variables persist in Main across ex and main_eval calls in the same session.

Note: REPL.call_on_backend(f, backend) (the function-call path) doesn't work for closures defined post-startup — repl_backend_loop calls f() directly without invokelatest, so any closure defined after the REPL backend started hits a world-age error. Only the AST path (eval_on_backend) is usable from ex.

Interesting observation

gate_eval already does the right thing for sync :eval (gate.jl:906–908): it detects has_repl && on_interactive and routes through REPL.call_on_backend(() -> _eval_with_capture(expr), backend). That closure is "old" because Kaimon was loaded before the REPL backend started, so it dodges the world-age issue. But the ex MCP tool always uses :eval_async (presumably so long-running evals can be promoted to background jobs), losing this routing.

Suggested fixes

A few options, ordered by my guess at impact-vs-effort:

  1. Add an opt-in flag to ex like main_thread=true that routes through eval_on_backend (AST path). Cheap, explicit, doesn't break the default async-promotion flow. Probably the right call for plotting workflows.
  2. Auto-detect GLMakie/GLFW imports and silently route those evals through the backend.
  3. Spawn :eval_async tasks with Threads.@spawn :default and a sticky pin to thread 1 when an active REPL backend exists. More invasive, may interact with promotion/cancellation.
  4. Document the main_eval workaround in the README under "Plotting" — at minimum a paste-able snippet so users hitting this don't have to spelunk gate.jl.

Happy to PR (1) or (4) if useful.

Environment

  • Kaimon: latest tagged release loaded from ~/.julia/packages/Kaimon/RY8VZ
  • Julia 1.12.5
  • Linux 6.18.7, NVIDIA GPU
  • --threads=auto,2 --gcthreads=1 (default kaimon launch flags)
## Summary The `ex` MCP tool routes evals to `:eval_async`, which uses `Threads.@spawn` (default pool). GLFW's `require_main_thread()` then trips on any GLMakie code: ``` ThreadAssertionError: Code must run on thread 1 but ran on thread N ``` This affects both **module init** (`using GLMakie`) and **every plot call** (`display(fig)`, screen creation), so plotting is unreachable from `ex` even after pre-loading GLMakie. This is a follow-up to #11 with a deterministic repro and root cause. ## Repro ```bash julia --startup-file=no --threads=auto,2 --gcthreads=1 -m Kaimon ``` Then via MCP `ex`: ```julia using GLMakie # → ThreadAssertionError at GLFW.SetErrorCallback in GLMakie's __init__ ``` Or, if GLMakie is already loaded: ```julia fig = Figure(); ax = Axis(fig[1,1]); scatter!(ax, [1,2,3], [4,5,6]) display(fig) # → ThreadAssertionError at GLFW.SetWindowSizeCallback ``` `Threads.threadid()` from inside `ex` confirms it lands on the default pool (e.g. thread 22). ## Root cause `gate.jl:1565` uses `Threads.@spawn` for `:eval_async`, which schedules onto the default thread pool — never guaranteed to be thread 1. GLFW's main-thread guard (GLFW.jl `src/GLFW.jl:26`) checks `Threads.threadid() == 1` on every callback registration. ## Workaround (verified working) Kaimon's REPL backend task lives on thread 1 and is exposed via `Base.active_repl.backendref`. `REPL.eval_on_backend(ast, backend)` sends an AST through the REPL channel, which `repl_backend_loop` processes via `toplevel_eval_with_hooks` (proper world-age handling). I define this once per session: ```julia main_eval(code::AbstractString) = REPL.eval_on_backend( Meta.parseall(code), Base.active_repl.backendref, ) ``` Then anything thread-1-sensitive is wrapped: ```julia main_eval("using GLMakie; GLMakie.activate!()") main_eval("display(fig)") main_eval("include(\"examples/pipeline/fit_homography.jl\")") ``` Works end-to-end — `using GLMakie`, figure creation, `display(fig)`, all on thread 1. Variables persist in `Main` across `ex` and `main_eval` calls in the same session. Note: `REPL.call_on_backend(f, backend)` (the function-call path) doesn't work for closures defined post-startup — `repl_backend_loop` calls `f()` directly without `invokelatest`, so any closure defined after the REPL backend started hits a world-age error. Only the AST path (`eval_on_backend`) is usable from `ex`. ## Interesting observation `gate_eval` already does the right thing for **sync** `:eval` (gate.jl:906–908): it detects `has_repl && on_interactive` and routes through `REPL.call_on_backend(() -> _eval_with_capture(expr), backend)`. That closure is "old" because Kaimon was loaded before the REPL backend started, so it dodges the world-age issue. But the `ex` MCP tool always uses `:eval_async` (presumably so long-running evals can be promoted to background jobs), losing this routing. ## Suggested fixes A few options, ordered by my guess at impact-vs-effort: 1. **Add an opt-in flag to `ex`** like `main_thread=true` that routes through `eval_on_backend` (AST path). Cheap, explicit, doesn't break the default async-promotion flow. Probably the right call for plotting workflows. 2. **Auto-detect GLMakie/GLFW imports** and silently route those evals through the backend. 3. **Spawn `:eval_async` tasks with `Threads.@spawn :default` and a sticky pin to thread 1** when an active REPL backend exists. More invasive, may interact with promotion/cancellation. 4. **Document the `main_eval` workaround** in the README under "Plotting" — at minimum a paste-able snippet so users hitting this don't have to spelunk gate.jl. Happy to PR (1) or (4) if useful. ## Environment - Kaimon: latest tagged release loaded from `~/.julia/packages/Kaimon/RY8VZ` - Julia 1.12.5 - Linux 6.18.7, NVIDIA GPU - `--threads=auto,2 --gcthreads=1` (default kaimon launch flags)
kahliburke commented 2026-04-10 18:24:09 -07:00 (Migrated from github.com)

@prittjam Funny that you mention this, because I've been running into exactly the same thing with some recent stuff I've been trying to do with GLMakie!

Got a couple of workarounds I've been using, but they're a little kludgy. So I would certainly entertain a PR if you want to take a stab at it. Otherwise, know that it's something on my mind and also near and dear to my heart?

@prittjam Funny that you mention this, because I've been running into exactly the same thing with some recent stuff I've been trying to do with GLMakie! Got a couple of workarounds I've been using, but they're a little kludgy. So I would certainly entertain a PR if you want to take a stab at it. Otherwise, know that it's something on my mind and also near and dear to my heart?
kahliburke commented 2026-04-12 09:43:44 -07:00 (Migrated from github.com)

Fixed in v1.2.2 (commit d95bfd5). The ex tool now accepts mt=true (main-thread mode) which routes evals through the REPL backend on thread 1.

ex(e="using GLMakie; GLMakie.activate!()", mt=true)
ex(e="fig = Figure(); scatter(fig[1,1], [1,2,3], [4,5,6]); display(fig)", mt=true)

Thanks for the excellent write-up @prittjam!

Fixed in v1.2.2 (commit d95bfd5). The `ex` tool now accepts `mt=true` (main-thread mode) which routes evals through the REPL backend on thread 1. ```julia ex(e="using GLMakie; GLMakie.activate!()", mt=true) ex(e="fig = Figure(); scatter(fig[1,1], [1,2,3], [4,5,6]); display(fig)", mt=true) ``` Thanks for the excellent write-up @prittjam!
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kahliburke/Kaimon.jl#21
No description provided.