main 602baa7

xturbo

A pure-Go X11 client runtime with generated bindings. The runtime speaks the X11 wire protocol over a non-blocking Unix socket; the bindings come from xcb-proto 1.17.0. It depends only on the standard library and golang.org/x/sys, builds with CGO_ENABLED=0, and keeps the steady-state request, reply, event and error paths free of allocations.

Module: bnema.dev/xturbo. Source: https://git.bnema.dev/xturbo.

Packages

Package Contents
bnema.dev/xturbo Conn, Dial, Buf, Dispatch, Expect, Sync, InternAtoms, XID allocation
xproto core protocol
bigreq, ge, xcmisc BIG-REQUESTS, Generic Event, XC-MISC
composite, xfixes, render, shape, res Composite, XFIXES, RENDER, SHAPE, X-Resource

The root package never imports the generated packages.

Install

go get bnema.dev/xturbo

Example

Dial (or NewConn(fd) for an already-connected socket) performs the handshake and negotiates the supported extensions. Requests are encoded into the connection's Buf and return a Seq (0 on failure). Flush never blocks, and Dispatch reads whatever the server sent and calls the Handler.

package xturbo_test

import (
	"errors"
	"log"

	"golang.org/x/sys/unix"

	"bnema.dev/xturbo"
	"bnema.dev/xturbo/xproto"
)

// Replies registered with Expect carry this kind (16 and above are free).
const kindGeometry uint16 = 16

func Example() {
	c, err := xturbo.Dial("") // "" means $DISPLAY
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	// Startup: blocking calls, bounded by a 5 s deadline.
	var atoms [1]uint32
	if err := c.InternAtoms([]string{"WM_DELETE_WINDOW"}, false, atoms[:]); err != nil {
		log.Fatal(err)
	}

	// Queue requests; they return a Seq (0 on failure, see c.Buf().Err()).
	b := c.Buf()
	win, err := c.NewXID()
	if err != nil {
		log.Fatal(err)
	}
	var v xproto.CreateWindowValues
	v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify)
	xproto.CreateWindow(b, 0, xproto.Window(win), xproto.Window(c.Root()),
		0, 0, 640, 480, 0, xproto.WindowClassInputOutput, 0, &v)
	xproto.MapWindow(b, xproto.Window(win))
	if seq := xproto.GetGeometry(b, xproto.Drawable(win)); seq != 0 {
		if err := c.Expect(seq, kindGeometry, win, 0, 0, 0); err != nil {
			log.Fatal(err)
		}
	}

	// Callbacks run inside Dispatch. Events, replies and errors are borrowed:
	// copy what you keep.
	h := &xturbo.Handler{
		OnEvent: func(ev *xturbo.Event) {
			switch ev.Kind {
			case xproto.KindExpose:
				e := xproto.ExposeOf(ev)
				log.Printf("expose %dx%d", e.Width(), e.Height())
			}
		},
		OnReply: func(r *xturbo.Reply) {
			if r.Kind == kindGeometry {
				if g, ok := xproto.ParseGetGeometryReply(r.Raw); ok {
					log.Printf("window %#x is %dx%d", r.Args[0], g.Width(), g.Height())
				}
			}
		},
		OnError: func(e *xturbo.XError) { log.Print(e) },
	}

	for {
		// Flush never blocks. On ErrWouldBlock, also wait for POLLOUT.
		events := int16(unix.POLLIN)
		if err := c.Flush(); errors.Is(err, xturbo.ErrWouldBlock) {
			events |= unix.POLLOUT
		} else if err != nil {
			log.Fatal(err)
		}
		fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
		if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {
			log.Fatal(err)
		}
		if err := c.Dispatch(h); err != nil { // io.EOF: server closed
			log.Fatal(err)
		}
		// Keep the XID pool topped up without blocking later.
		if c.XIDsLow() {
			_ = c.PrefetchXIDs()
		}
	}
}

Rules

  • Lifetimes. A request's body slice is valid only until the next request is encoded. Reply views (Reply.Raw and the parsed views) and event Tail are valid only during the callback; Event.Raw is a copy. Copy what you keep.
  • Callbacks. They may queue requests, call Expect and PrefetchXIDs. They must not call Dispatch, Sync, InternAtoms, or NewXID when it needs a refill.
  • Blocking. Dial and NewConn block for at most 10 s during the handshake. Only Sync, InternAtoms and a NewXID refill block after that, for at most 5 s, and are meant for startup. Use XIDsLow and PrefetchXIDs to keep the event loop non-blocking.
  • Errors. A failed Buf is sticky: later requests return 0 and Flush returns the error.

Performance

Measured with make bench on an AMD Ryzen 9 7900X3D (Go 1.27, CGO_ENABLED=0). Every benchmark reports 0 allocations per operation.

Benchmark Result
Encode a request (MapWindow) 6.2 ns
Encode and flush 64 requests over a socketpair 1.55 µs (about 24 ns per request)
Decode 10k events 124 µs (about 12 ns per event)
Dispatch 10k mixed packets 251 µs (about 25 ns per packet)
Parse a GetProperty reply view 1.9 ns
Parse a QueryTree reply view 3.2 ns

Regenerating bindings

Generated code lives in the extension packages and xproto; do not edit it. The inputs are the pinned xcb-proto 1.17.0 files in spec/, listed with checksums in spec/manifest.json.

make gen        # go generate ./... (cmd/xtgen)
make gen-check  # regenerate and fail on any difference
go run ./cmd/xtsync   # refresh spec/ from upstream and update the manifest

Tests

make check     # vet, staticcheck, tests, race, allocation, poison, gen-check
make xserver   # tests against a real Xvfb (needs xvfb and xtrace)
make fuzz      # root targets for FUZZTIME (60s), generated ones for GENFUZZTIME (10s)
make bench     # benchmarks

License

MIT, see LICENSE. The xcb-proto XML and its license are under LICENSES/.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 1

↑↓ moveenter openesc close