3 Event Loops
dxtr edited this page 2026-10-14 03:53:18 +02:00

Event Loops

cl-wl does nothing in the background. Events are read and handled only when you call one of the dispatch functions, in the thread that calls it.

The dispatch functions

Function Sends buffered requests Waits Reads Runs handlers Returns
(dispatch display &key timeout) yes until events arrive or timeout yes yes events delivered
(dispatch-pending display) no no no yes, for events already read events delivered
(roundtrip display) yes until the compositor has handled every request so far yes yes events delivered
(read-events display &key timeout) yes until events arrive or timeout yes no true if there may be events to deliver
(flush display) yes no no no nothing

timeout is in seconds and may be any non-negative real. Without a timeout the function waits indefinitely, and a timeout of 0 does not wait at all. A wait interrupted by a signal, as SBCL uses to stop threads for garbage collection, resumes with the time that is left.

A simple loop

(loop until *quit*
      do (wl:dispatch display))

This loop blocks until the compositor sends something. A game or any program that draws continuously either uses frame callbacks (see Recipes) or polls without waiting:

(loop until *quit*
      do (wl:dispatch display :timeout 0)   ; handle what has arrived, don't wait
         (update-and-draw))

Waiting for an answer: roundtrip

roundtrip sends everything and blocks until the compositor has processed it, delivering every event that arrives in the meantime. Use it during setup, when you need the compositor's answer before going on. For example, wl_seat's capabilities arrive right after binding:

(let ((seat (wl:bind display 'wl-seat :version 7))
      (capabilities '()))
  (wl:set-event-handlers seat
    :capabilities (lambda (seat caps)
                    (declare (ignore seat))
                    (setf capabilities caps)))
  (wl:roundtrip display)
  capabilities)                     ; => (:POINTER :KEYBOARD)

Do not use roundtrip every frame. It costs a full trip to the compositor and back.

wl_display.sync is the explicit form of the same idea. It creates a wl_callback whose :done event arrives once everything before it has been handled. :done ends the callback, so cl-wl destroys it after the handler:

(let ((done nil))
  (wl:set-event-handlers (wl-display-sync display)
    :done (lambda (callback serial)
            (declare (ignore callback serial))
            (setf done t)))
  (loop until done do (wl:dispatch display)))

Integrating with another event loop: display-fd

display-fd returns the connection's file descriptor. Wait on it with poll, epoll or your toolkit's loop, then call dispatch with a timeout of 0:

(wl:flush display)                         ; send requests before sleeping
;; … your loop waits until (wl:display-fd display) is readable …
(wl:dispatch display :timeout 0)

The fd belongs to the display. Do not close it or read from it.

If you wait on the fd yourself, flush before you sleep. Otherwise requests may sit in libwayland's buffer while the compositor waits for them. dispatch and read-events flush on their own.

An event thread: read-events and dispatch-pending

A program can have an event thread that waits for the compositor while other threads, such as a render thread, use the same display. For that program, dispatch splits in two:

  • read-events waits for events and reads them into libwayland's queue, without running handlers. It is the one function that may run while another thread uses the display.
  • dispatch-pending runs the handlers for the events that have been read.

Guard the display with a lock. Call read-events without holding the lock, and call everything else, including dispatch-pending, every request and flush, while holding it:

(asdf:load-system :cl-threads)

(defvar *lock* (cl-threads:make-lock "wayland display"))

(defun event-thread (display)
  (loop until *quit*
        do (when (wl:read-events display :timeout 0.1)
             (cl-threads:with-lock (*lock*)
               (wl:dispatch-pending display)
               (wl:flush display)))))

;; Any other thread:
(cl-threads:with-lock (*lock*)
  (wl-surface-commit surface)
  (wl:flush display))

read-events returns true if there may be events to deliver. That is either because it read some, or because some had already been read before it was called; in the second case it returns at once without waiting. It returns NIL when the timeout ran out.

display-error and flush may also be called while another thread waits in read-events.

Errors in the loop

Every dispatch function, read-events and flush signal compositor-error or connection-error once the connection has failed. A failed connection stays failed, so catch the error outside the loop rather than retrying:

(handler-case
    (loop until *quit* do (wl:dispatch display))
  (wl:compositor-error (e)
    (format *error-output* "Protocol error: ~A~%" e))
  (wl:connection-error (e)
    (format *error-output* "Lost the compositor: ~A~%" e)))

A dispatch function also signals event-error when an event could not be decoded. The event is dropped, but the connection is still fine, so you may continue dispatching. See Errors.

Cost

Dispatching allocates nothing in steady state. That covers waiting for events, however briefly, queueing an event, calling its handler with up to ten arguments, and sending a request. The exceptions are values that must be new Lisp objects: strings, arrays, new proxies, and the first occurrence of each bitfield value. See Lifetimes and Threads.