Trouble running multiple infinite loops (GTK + X11 + Signal listener) in parallel in Crystal

I have an applet originally written in C, and after learning Crystal I decided to rewrite it.
Everything went fine until I reached the part that needs multiple infinite loops running in parallel.

In the original C version I had three concurrent loops:

  1. The GTK main loop
  2. An X11 key listener
  3. A signal listener (SIGUSR1)

When I tried to do the same in Crystal, the program either froze, lagged, or crashed depending on which threading method I used.

Here is the X11 loop:

def keys
  Xlib.call do
    xInitThreads()
    dpy = xOpenDisplay(nil)
    unless dpy
      STDERR.puts("Cannot open display")
      return
    end
    root = xRootWindow(dpy, 0)

    xGrabKey(dpy, xKeysymToKeycode(dpy, X11::XF86XK_AudioLowerVolume),
             X11::AnyModifier, root, 1, X11::GrabModeAsync, X11::GrabModeAsync)
    xGrabKey(dpy, xKeysymToKeycode(dpy, X11::XF86XK_AudioRaiseVolume),
             X11::AnyModifier, root, 1, X11::GrabModeAsync, X11::GrabModeAsync)
    xGrabKey(dpy, xKeysymToKeycode(dpy, X11::XF86XK_AudioMute),
             X11::AnyModifier, root, 1, X11::GrabModeAsync, X11::GrabModeAsync)

    xSelectInput(dpy, root, X11::KeyPressMask)
    ev = uninitialized LRoot::XEvent

    loop do
      xNextEvent(dpy, pointerof(ev))
      if ev.type == X11::KeyPress
        keysym = xKeycodeToKeysym(dpy, ev.key.keycode, 0)
        case keysym
        when X11::XF86XK_AudioRaiseVolume
          Volume.volume_up
          Volume.update_safe(true)
        when X11::XF86XK_AudioLowerVolume
          Volume.volume_down
          Volume.update_safe(true)
        when X11::XF86XK_AudioMute
          Volume.volume_mute
          Volume.update_safe(false)
        end
      end
    end
  end
end

Signal handling code:

private def pid; "/tmp/volume-pulse.pid"; end

def signal_root
  File.write(pid, "#{Process.pid}")
  Signal::USR1.trap { Volume.update_safe(true) }
  sleep
end

def signal_update
  return unless File.exists?(pid)
  Process.signal(Signal::USR1, File.read(pid).to_i)
end

GTK loop:

Xlib.call do
  notify("Volume Pulse") if ConfigVariables.use_notifications
  fcInit()
  gtk2_init(nil, nil)

  icon_tray = gtk2_status_new()
  Volume.icon_statusX do |volume|
    Xlib.call do
      if Volume.get_mute
        gtk2_status_icon_tooltip_text(icon_tray, "Volume: Muted")
        gtk2_status_set_icon_name(icon_tray, "audio-volume-muted")
      else
        gtk2_status_icon_tooltip_text(icon_tray, "Volume: #{volume}")
        case volume
        when 0
          gtk2_status_set_icon_name(icon_tray, "audio-volume-muted")
        when 1..30
          gtk2_status_set_icon_name(icon_tray, "audio-volume-low")
        when 31..70
          gtk2_status_set_icon_name(icon_tray, "audio-volume-medium")
        else
          gtk2_status_set_icon_name(icon_tray, "audio-volume-high")
        end
      end
    end
  end

  Volume.update

  g_signal_connect(icon_tray.as(LRoot::GObject*), "scroll-event", Event.scroll.as(LRoot::GCallback*), nil)
  g_signal_connect(icon_tray.as(LRoot::GObject*), "button-press-event", Event.button.as(LRoot::GCallback*), nil)
  g_signal_connect(icon_tray.as(LRoot::GObject*), "popup-menu", GUI.menu.as(LRoot::GCallback*), nil)

  gtk2_main()
  notify_uninit() if ConfigVariables.use_notifications
end

Methods I have tried:

  • g_thread_new and pthread_create — both cause freezing or crashes
  • Thread.new and spawn (with -Dpreview-mt) — sometimes only part of the signals are received, or it crashes after a short time, or one of the loops stops running completely

If I remove the GTK loop, everything works fine.
But as soon as I call gtk2_main, all other threads or fibers stop responding.

Is there a reliable way to run multiple native event loops (GTK, X11, signal listener) in true parallel execution in Crystal?
Or should I move one of these loops (for example the X11 listener) into a small C helper process instead?

Crystal version: Crystal 1.17.1 (2025-07-22)
OS: Artix Linux (Openrc)
LLVM: 20.1.8
Default target: x86_64-pc-linux-gnu

Give Fiber::ExecutionContext a try

1 Like

Execution contexts could help. Each blocking loop should run in a separate instance of ExecutionContext::Isolated.

However, there’s also a good chance that some of the code you’re using is just not multi-threading safe. That applies to parts of stdlib, as well as many 3rd party shards.
See Charting the route to multi-threading support for some details on how potential thread-safety issues.

You could try to reduce the code that runs in the blocking loops to a minimum. They can just trigger a notification - for example by sending to a channel - while the actual handler runs in the single-threaded main context - at the receiving end of the channel. That should avoid many thread-safety concerns because most of the code runs in a single thread.

2 Likes

Thank you so much,Your guidance helped me a lot and I could solve the issues

1 Like

You better update this section.

1 Like