X11 F05 — loop wakeup & external fds
How a second thread wakes an X11 event loop, measured both ways. The demo, ./examples/f05-loop-wakeup/app.d, extends the scaffold to the full F05 spec: an injector thread fires each wakeup mechanism 10×/second for 30 seconds — mechanism A, an XSendEvent ClientMessage posted from the thread's own second Display* connection (the documented no-XInitThreads pattern), and mechanism B, an eventfd(2) in the main poll(2) set right next to the connection fd — while a periodic timerfd(2) plays the arbitrary-external-fd probe, its ticks interleaved with window events. Every wakeup carries a monotonic timestamp and logs wakeup latency_us=… mech=…; distributions are reported at exit. Tier A under xvfb-run (Xvfb, no WM, WSI_AUTO_EXIT=1): 600 wakeups + 121 fd ticks, exit 0.
Last reviewed: June 11, 2026
The verdict lines
30300645 f05_x11 stats mech=clientmessage n=300 min=40 p50=71 p99=155 max=808
30300686 f05_x11 stats mech=eventfd n=300 min=5 p50=17 p99=44 max=65
30300716 f05_x11 stats mech=timerfd ticks=121 period_ms=250| Mechanism | n | min µs | p50 µs | p99 µs | max µs |
|---|---|---|---|---|---|
ClientMessage (XSendEvent) | 300 | 40 | 71 | 155 | 808 |
eventfd write | 300 | 5 | 17 | 44 | 65 |
The ClientMessage is ~4× slower at the median and ~12× worse at the tail, because it is a full server round-trip: injector thread → XSendEvent marshal → XFlush → server socket → X server schedules and re-emits the event → main connection socket → poll wake → XNextEvent. The eventfd path is one kernel wake with no server involvement. Both are far below human-perceptible; the difference is architectural, not practical — until the X server is busy (the 808 µs max landed while Xvfb was servicing other clients' traffic; the eventfd tail cannot be touched by server load at all).
Mechanism A — ClientMessage from a second connection
X11 does have a native user-defined event — unlike Wayland, where the F05 spec notes the absence itself is the finding. Any client may construct a ClientMessage (type 33, never generated by the server, reserved for client-to-client traffic) and post it with XSendEvent:
The
XSendEventfunction identifies the destination window, determines which clients should receive the specified events, and ignores any active grabs.
The demo's injector thread opens its own Display* and targets the main window's XID (XIDs are server-global, valid on any connection):
407 f05_x11 step name=thread_start wakeups_per_mech=300 period_ms=100
813 f05_x11 step name=XOpenDisplay conn=injector fd=6
50958 f05_x11 wakeup latency_us=67 mech=clientmessageTwo wire details the implementation must respect:
data.lslots are 32-bit on the wire.XClientMessageEvent.data.lis declaredlong[5], but withformat=32each slot is truncated to 32 bits by the protocol and sign-extended by the receiving Xlib. The 64-bit monotonic timestamp must be split across two slots and reassembled with the low half cast unsigned (app.ddoes exactly that).- Nothing moves until
XFlush.XSendEventonly marshals into the injector connection's output buffer — the scaffold's output-buffer model. Without the explicit flush the "wakeup" sits client-side until the next unrelated request.
Thread-safety: why no XInitThreads
Per the Xlib manual, XInitThreads is required only when multiple threads share one Display:
The
XInitThreadsfunction initializes Xlib support for concurrent threads. This function must be the first Xlib function a multi-threaded program calls, and it must complete before any other Xlib call is made.
One-connection-per-thread sidesteps the requirement entirely: each Display has its own socket, output buffer, and event queue, and no Xlib state is shared. This is the documented, lock-free injection shape — the same pattern the F02 external-resize demo used for its second connection. The cost is one extra socket and one extra server client slot per injecting thread.
Mechanism B — an eventfd beside the connection fd
The X11 loop is already fd-based: per Xlib, ConnectionNumber (reached via its function form XConnectionNumber — the macro is one of the scaffold's ImportC gaps)
returns a connection number for the specified display. On a POSIX-conformant system, this is the file descriptor of the connection.
So adding a user wakeup is just one more pollfd. The demo uses an eventfd, which exists precisely for this (per eventfd(2)):
Applications can use an eventfd file descriptor instead of a pipe (see
pipe(2)) in all cases where a pipe is used simply to signal events.
99989 f05_x11 wakeup latency_us=14 mech=eventfd coalesced=1One semantic wrinkle: an eventfd is a counter, not a queue — concurrent writes coalesce into one readable value. A timestamp therefore cannot travel through the fd itself; the demo carries it in an atomic side-channel and logs the read counter as coalesced= (it stayed 1 for all 300 wakeups at this rate; a self-pipe carrying 8-byte payloads is the alternative when payloads must not merge). The writer side is just write(2) — async-signal-safe, callable from any thread or even a signal handler, no X connection needed — which is also why this mechanism is the only one of the two that works before XOpenDisplay or after the connection dies.
The arbitrary-fd probe — timerfd ticks interleaved
A periodic 250 ms timerfd sat in the same poll set for the whole run and ticked 121 times in 30.3 s, interleaved with both wakeup streams and the window's Expose traffic:
250283 f05_x11 wakeup latency_us=87 mech=clientmessage
250351 f05_x11 fd_tick t=250351 expirations=1 src=timerfd
299989 f05_x11 wakeup latency_us=14 mech=eventfd coalesced=1There is no "where the native loop cannot accept the primitive" story on X11: any fd-shaped source (timers, sockets, IPC, an async runtime's reactor) joins the loop with zero adapters. This is the readiness-model integration shape at its simplest — one poll/epoll set, the display connection is just one entry in it.
WARNING
The one X11-specific trap when multiplexing: XPending both flushes and reads. Events can sit pre-read in Xlib's client-side queue while the socket shows no readable data, so the loop must drain XPending to zero before sleeping in poll — the scaffold loop structure. Sleeping first deadlocks on queued-but-already-read events.
Findings
- Latency:
eventfdp50 17 µs / p99 44 µs;ClientMessagep50 71 µs / p99 155 µs / max 808 µs. The round-trip through the server costs ~4× at the median, and couples the wakeup tail to server load. - X11 has a native user event (
ClientMessage+XSendEvent), but the fd-loop alternative is both trivially available (ConnectionNumberis a socket fd) and strictly faster. A framework targeting X11 needs no protocol-level user event at all — the integration shape to offer is fd-based readiness (concepts: readiness vs completion), same as Wayland; theClientMessagepath matters only when the wakeup must be visible to other X clients or ordered within the X event stream. - Thread-safety rules:
XSendEventfrom a second thread requires either a dedicatedDisplayper thread (used here, no locking) orXInitThreadsbefore any other Xlib call (process-wide locking). Theeventfd/self-pipe write has no rules at all — any thread, any time, even signal handlers. - Ordering: the
ClientMessageis serialized into the X event stream (after in-flightExpose/ConfigureNotifytraffic); theeventfdwake is unordered relative to X events. A run-queue drained at a fixed point in the loop turn (see manual run queue) makes the two equivalent in practice.
Build and run
nix develop -c dub build --root=docs/research/window-system-integration/os-apis/x11/examples/f05-loop-wakeup
nix develop -c xvfb-run -a env WSI_AUTO_EXIT=1 \
docs/research/window-system-integration/os-apis/x11/examples/f05-loop-wakeup/build/f05_loop_wakeup_x11The demo is fully self-driving (the injector thread is the input source), so there is no separate driver script. WSI_DURATION_MS shortens the 30 s wakeup phase for iteration; no reachable display prints SKIP: no X11 display and exits 0.
Sources
- F05 spec — requirements 1–3 (the 10 Hz × 30 s schedule, the per-platform mechanism pairs, the arbitrary-fd probe, exit stats).
- X11 scaffold findings — the
poll-driven loop, the output-buffer/flush model, and the ImportC macro gaps this demo inherits. - Xlib — C Language X Interface —
XInitThreads(verbatim above),ConnectionNumber(verbatim above), the event-queue functions. XSendEvent(Tronche Xlib reference) — semantics quoted above;ClientMessageevents "are never generated by the server".eventfd(2)andtimerfd_create(2)— the two kernel primitives (eventfd-instead-of-pipe quote above; counter semantics).- Concepts — readiness vs completion — where this integration shape sits in the cross-platform picture; the manual run queue note for the dispatch side.
- Demo sources:
app.d,instrument.d, and thec.cImportC shim alongside them.