detached 089cffc

fix(server): wake the display loop on Do instead of waiting for the dispatch timeout

Do queued its call and the loop only ran it after wl_event_loop_dispatch returned, so each call could wait up to the 5 ms timeout. A compositor sending one call per pointer event was capped near 150 calls per second. An eventfd in the event loop now wakes it at once; calls are drained in a batch, and the eventfd source is removed before the display is destroyed.
commit
089cffc58cf916511ce8f1fcff82a45ca0ec35b4
author
brice · · 2026-09-24 16:03 UTC
parent
4df41d0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +109 -15
M server/display.go +54 -15
@@ -43,10 +43,16 @@ var live = struct {
4343
4444 // Display owns a wl_display. All methods except Do must run on the goroutine
4545 // that calls Run, or before Run starts.
46+// Do wakes the loop through an eventfd so calls run at once instead of after
47+// the dispatch timeout.
4648 type Display struct {
47- c uintptr
48- loop uintptr
49- calls chan func()
49+ c uintptr
50+ loop uintptr
51+ calls chan func()
52+ wake int
53+ // wakeSrc owns libwayland's duplicate of wake; only removing the source
54+ // closes it, destroying the display does not.
55+ wakeSrc uintptr
5056 stopped chan struct{}
5157 state atomic.Uint32
5258 }
@@ -59,7 +65,19 @@ func NewDisplay() (*Display, error) {
5965 if c == 0 {
6066 return nil, errors.New("purego-libwayland: wl_display_create failed")
6167 }
62- return &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func()), stopped: make(chan struct{})}, nil
68+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
69+ if err != nil {
70+ wlDisplayDestroy(c)
71+ return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
72+ }
73+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{})}
74+ // The callback only drains the counter; Run then executes queued calls.
75+ if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
76+ unix.Close(wake)
77+ wlDisplayDestroy(c)
78+ return nil, errors.New("purego-libwayland: wl_event_loop_add_fd failed")
79+ }
80+ return d, nil
6381 }
6482
6583 // AddSocketFD serves clients on an already bound, listening unix socket.
@@ -87,9 +105,16 @@ func (d *Display) Close() {
87105 if !d.state.CompareAndSwap(0, 1) {
88106 return
89107 }
108+ d.destroy()
109+ close(d.stopped)
110+}
111+
112+// destroy frees the display and the wake-up fds.
113+func (d *Display) destroy() {
90114 wlDisplayDestroyClients(d.c)
115+ wlEventSourceRemove(d.wakeSrc)
91116 wlDisplayDestroy(d.c)
92- close(d.stopped)
117+ unix.Close(d.wake)
93118 }
94119
95120 // Run dispatches libwayland on one OS-locked goroutine until ctx is done.
@@ -100,19 +125,24 @@ func (d *Display) Run(ctx context.Context) error {
100125 runtime.LockOSThread()
101126 defer runtime.UnlockOSThread()
102127 defer close(d.stopped)
103- defer func() {
104- wlDisplayDestroyClients(d.c)
105- wlDisplayDestroy(d.c)
106- }()
128+ defer d.destroy()
107129 for {
108- select {
109- case <-ctx.Done():
130+ if ctx.Err() != nil {
110131 return nil
111- case fn := <-d.calls:
112- fn()
113- default:
114132 }
115- if wlEventLoopDispatch(d.loop, 5) < 0 {
133+ // Run every queued call; each Do also wrote the eventfd.
134+ drain:
135+ for {
136+ select {
137+ case fn := <-d.calls:
138+ fn()
139+ default:
140+ break drain
141+ }
142+ }
143+ wlDisplayFlushClients(d.c)
144+ // The timeout only bounds how late ctx cancellation is seen.
145+ if wlEventLoopDispatch(d.loop, 100) < 0 {
116146 return errors.New("purego-libwayland: wl_event_loop_dispatch failed")
117147 }
118148 wlDisplayFlushClients(d.c)
@@ -129,6 +159,8 @@ func (d *Display) Do(fn func()) bool {
129159 case <-d.stopped:
130160 return false
131161 }
162+ var one = [8]byte{1}
163+ _, _ = unix.Write(d.wake, one[:])
132164 select {
133165 case <-done:
134166 return true
@@ -271,6 +303,13 @@ func destroyed(res uintptr) {
271303 }
272304 }
273305
306+// wake drains a Display's eventfd (wl_event_loop_fd_func_t).
307+func wake(fd int32, mask uint32, data uintptr) int32 {
308+ var buf [8]byte
309+ _, _ = unix.Read(int(fd), buf[:])
310+ return 0
311+}
312+
274313 func bind(client uintptr, data uintptr, version uint32, id uint32) {
275314 if fn := live.binds[data]; fn != nil {
276315 fn(Client{client}, version, id)
@@ -43,10 +43,16 @@ var live = struct {
43 43
44 // Display owns a wl_display. All methods except Do must run on the goroutine44 // Display owns a wl_display. All methods except Do must run on the goroutine
45 // that calls Run, or before Run starts.45 // that calls Run, or before Run starts.
46+// Do wakes the loop through an eventfd so calls run at once instead of after
47+// the dispatch timeout.
46 type Display struct {48 type Display struct {
47- c uintptr49+ c uintptr
48- loop uintptr50+ loop uintptr
49- calls chan func()51+ calls chan func()
52+ wake int
53+ // wakeSrc owns libwayland's duplicate of wake; only removing the source
54+ // closes it, destroying the display does not.
55+ wakeSrc uintptr
50 stopped chan struct{}56 stopped chan struct{}
51 state atomic.Uint3257 state atomic.Uint32
52 }58 }
@@ -59,7 +65,19 @@ func NewDisplay() (*Display, error) {
59 if c == 0 {65 if c == 0 {
60 return nil, errors.New("purego-libwayland: wl_display_create failed")66 return nil, errors.New("purego-libwayland: wl_display_create failed")
61 }67 }
62- return &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func()), stopped: make(chan struct{})}, nil68+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
69+ if err != nil {
70+ wlDisplayDestroy(c)
71+ return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
72+ }
73+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{})}
74+ // The callback only drains the counter; Run then executes queued calls.
75+ if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
76+ unix.Close(wake)
77+ wlDisplayDestroy(c)
78+ return nil, errors.New("purego-libwayland: wl_event_loop_add_fd failed")
79+ }
80+ return d, nil
63 }81 }
64 82
65 // AddSocketFD serves clients on an already bound, listening unix socket.83 // AddSocketFD serves clients on an already bound, listening unix socket.
@@ -87,9 +105,16 @@ func (d *Display) Close() {
87 if !d.state.CompareAndSwap(0, 1) {105 if !d.state.CompareAndSwap(0, 1) {
88 return106 return
89 }107 }
108+ d.destroy()
109+ close(d.stopped)
110+}
111+
112+// destroy frees the display and the wake-up fds.
113+func (d *Display) destroy() {
90 wlDisplayDestroyClients(d.c)114 wlDisplayDestroyClients(d.c)
115+ wlEventSourceRemove(d.wakeSrc)
91 wlDisplayDestroy(d.c)116 wlDisplayDestroy(d.c)
92- close(d.stopped)117+ unix.Close(d.wake)
93 }118 }
94 119
95 // Run dispatches libwayland on one OS-locked goroutine until ctx is done.120 // Run dispatches libwayland on one OS-locked goroutine until ctx is done.
@@ -100,19 +125,24 @@ func (d *Display) Run(ctx context.Context) error {
100 runtime.LockOSThread()125 runtime.LockOSThread()
101 defer runtime.UnlockOSThread()126 defer runtime.UnlockOSThread()
102 defer close(d.stopped)127 defer close(d.stopped)
103- defer func() {128+ defer d.destroy()
104- wlDisplayDestroyClients(d.c)
105- wlDisplayDestroy(d.c)
106- }()
107 for {129 for {
108- select {130+ if ctx.Err() != nil {
109- case <-ctx.Done():
110 return nil131 return nil
111- case fn := <-d.calls:
112- fn()
113- default:
114 }132 }
115- if wlEventLoopDispatch(d.loop, 5) < 0 {133+ // Run every queued call; each Do also wrote the eventfd.
134+ drain:
135+ for {
136+ select {
137+ case fn := <-d.calls:
138+ fn()
139+ default:
140+ break drain
141+ }
142+ }
143+ wlDisplayFlushClients(d.c)
144+ // The timeout only bounds how late ctx cancellation is seen.
145+ if wlEventLoopDispatch(d.loop, 100) < 0 {
116 return errors.New("purego-libwayland: wl_event_loop_dispatch failed")146 return errors.New("purego-libwayland: wl_event_loop_dispatch failed")
117 }147 }
118 wlDisplayFlushClients(d.c)148 wlDisplayFlushClients(d.c)
@@ -129,6 +159,8 @@ func (d *Display) Do(fn func()) bool {
129 case <-d.stopped:159 case <-d.stopped:
130 return false160 return false
131 }161 }
162+ var one = [8]byte{1}
163+ _, _ = unix.Write(d.wake, one[:])
132 select {164 select {
133 case <-done:165 case <-done:
134 return true166 return true
@@ -271,6 +303,13 @@ func destroyed(res uintptr) {
271 }303 }
272 }304 }
273 305
306+// wake drains a Display's eventfd (wl_event_loop_fd_func_t).
307+func wake(fd int32, mask uint32, data uintptr) int32 {
308+ var buf [8]byte
309+ _, _ = unix.Read(int(fd), buf[:])
310+ return 0
311+}
312+
274 func bind(client uintptr, data uintptr, version uint32, id uint32) {313 func bind(client uintptr, data uintptr, version uint32, id uint32) {
275 if fn := live.binds[data]; fn != nil {314 if fn := live.binds[data]; fn != nil {
276 fn(Client{client}, version, id)315 fn(Client{client}, version, id)
M server/display_test.go +49 -0
@@ -3,6 +3,7 @@ package server
33 import (
44 "context"
55 "golang.org/x/sys/unix"
6+ "os"
67 "testing"
78 "time"
89 "unsafe"
@@ -134,3 +135,51 @@ func TestCloseBeforeRun(t *testing.T) {
134135 d.Close()
135136 d.Close()
136137 }
138+
139+// Do must not wait for the dispatch timeout: a compositor sends one call per
140+// pointer event, at up to 1000 per second.
141+func TestDoLatency(t *testing.T) {
142+ d, err := NewDisplay()
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ ctx, cancel := context.WithCancel(context.Background())
147+ returned := make(chan error, 1)
148+ go func() { returned <- d.Run(ctx) }()
149+ defer func() { cancel(); <-returned }()
150+ start := time.Now()
151+ for range 1000 {
152+ if !d.Do(func() {}) {
153+ t.Fatal("Do failed")
154+ }
155+ }
156+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
157+ t.Fatalf("1000 calls took %v", elapsed)
158+ }
159+}
160+
161+func TestCloseReleasesFDs(t *testing.T) {
162+ count := func() int {
163+ es, err := os.ReadDir("/proc/self/fd")
164+ if err != nil {
165+ t.Skip(err)
166+ }
167+ return len(es)
168+ }
169+ d, err := NewDisplay()
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ d.Close() // first display loads the library; measure the next ones
174+ before := count()
175+ for range 3 {
176+ d, err := NewDisplay()
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ d.Close()
181+ }
182+ if after := count(); after != before {
183+ t.Fatalf("fd leak: %d -> %d", before, after)
184+ }
185+}
@@ -3,6 +3,7 @@ package server
3 import (3 import (
4 "context"4 "context"
5 "golang.org/x/sys/unix"5 "golang.org/x/sys/unix"
6+ "os"
6 "testing"7 "testing"
7 "time"8 "time"
8 "unsafe"9 "unsafe"
@@ -134,3 +135,51 @@ func TestCloseBeforeRun(t *testing.T) {
134 d.Close()135 d.Close()
135 d.Close()136 d.Close()
136 }137 }
138+
139+// Do must not wait for the dispatch timeout: a compositor sends one call per
140+// pointer event, at up to 1000 per second.
141+func TestDoLatency(t *testing.T) {
142+ d, err := NewDisplay()
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ ctx, cancel := context.WithCancel(context.Background())
147+ returned := make(chan error, 1)
148+ go func() { returned <- d.Run(ctx) }()
149+ defer func() { cancel(); <-returned }()
150+ start := time.Now()
151+ for range 1000 {
152+ if !d.Do(func() {}) {
153+ t.Fatal("Do failed")
154+ }
155+ }
156+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
157+ t.Fatalf("1000 calls took %v", elapsed)
158+ }
159+}
160+
161+func TestCloseReleasesFDs(t *testing.T) {
162+ count := func() int {
163+ es, err := os.ReadDir("/proc/self/fd")
164+ if err != nil {
165+ t.Skip(err)
166+ }
167+ return len(es)
168+ }
169+ d, err := NewDisplay()
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ d.Close() // first display loads the library; measure the next ones
174+ before := count()
175+ for range 3 {
176+ d, err := NewDisplay()
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ d.Close()
181+ }
182+ if after := count(); after != before {
183+ t.Fatalf("fd leak: %d -> %d", before, after)
184+ }
185+}
M server/lib.go +6 -0
@@ -18,6 +18,8 @@ var (
1818 wlDisplayFlushClients func(display uintptr)
1919 wlEventLoopDispatch func(loop uintptr, timeout int32) int32
2020 wlEventLoopGetFD func(loop uintptr) int32
21+ wlEventLoopAddFD func(loop uintptr, fd int32, mask uint32, fn, data uintptr) uintptr
22+ wlEventSourceRemove func(source uintptr) int32
2123 wlGlobalCreate func(display, iface uintptr, version int32, data uintptr, bind uintptr) uintptr
2224 wlResourceCreate func(client, iface uintptr, version int32, id uint32) uintptr
2325 wlResourceSetDispatcher func(resource, dispatcher, impl, data, destroy uintptr)
@@ -38,6 +40,7 @@ var (
3840 cbDispatcher uintptr
3941 cbDestroy uintptr
4042 cbBind uintptr
43+ cbWake uintptr
4144 )
4245
4346 func load() error {
@@ -61,6 +64,8 @@ func load() error {
6164 reg(&wlDisplayFlushClients, "wl_display_flush_clients")
6265 reg(&wlEventLoopDispatch, "wl_event_loop_dispatch")
6366 reg(&wlEventLoopGetFD, "wl_event_loop_get_fd")
67+ reg(&wlEventLoopAddFD, "wl_event_loop_add_fd")
68+ reg(&wlEventSourceRemove, "wl_event_source_remove")
6469 reg(&wlGlobalCreate, "wl_global_create")
6570 reg(&wlResourceCreate, "wl_resource_create")
6671 reg(&wlResourceSetDispatcher, "wl_resource_set_dispatcher")
@@ -74,6 +79,7 @@ func load() error {
7479 cbDispatcher = purego.NewCallback(dispatch)
7580 cbDestroy = purego.NewCallback(destroyed)
7681 cbBind = purego.NewCallback(bind)
82+ cbWake = purego.NewCallback(wake)
7783 })
7884 return loadErr
7985 }
@@ -18,6 +18,8 @@ var (
18 wlDisplayFlushClients func(display uintptr)18 wlDisplayFlushClients func(display uintptr)
19 wlEventLoopDispatch func(loop uintptr, timeout int32) int3219 wlEventLoopDispatch func(loop uintptr, timeout int32) int32
20 wlEventLoopGetFD func(loop uintptr) int3220 wlEventLoopGetFD func(loop uintptr) int32
21+ wlEventLoopAddFD func(loop uintptr, fd int32, mask uint32, fn, data uintptr) uintptr
22+ wlEventSourceRemove func(source uintptr) int32
21 wlGlobalCreate func(display, iface uintptr, version int32, data uintptr, bind uintptr) uintptr23 wlGlobalCreate func(display, iface uintptr, version int32, data uintptr, bind uintptr) uintptr
22 wlResourceCreate func(client, iface uintptr, version int32, id uint32) uintptr24 wlResourceCreate func(client, iface uintptr, version int32, id uint32) uintptr
23 wlResourceSetDispatcher func(resource, dispatcher, impl, data, destroy uintptr)25 wlResourceSetDispatcher func(resource, dispatcher, impl, data, destroy uintptr)
@@ -38,6 +40,7 @@ var (
38 cbDispatcher uintptr40 cbDispatcher uintptr
39 cbDestroy uintptr41 cbDestroy uintptr
40 cbBind uintptr42 cbBind uintptr
43+ cbWake uintptr
41 )44 )
42 45
43 func load() error {46 func load() error {
@@ -61,6 +64,8 @@ func load() error {
61 reg(&wlDisplayFlushClients, "wl_display_flush_clients")64 reg(&wlDisplayFlushClients, "wl_display_flush_clients")
62 reg(&wlEventLoopDispatch, "wl_event_loop_dispatch")65 reg(&wlEventLoopDispatch, "wl_event_loop_dispatch")
63 reg(&wlEventLoopGetFD, "wl_event_loop_get_fd")66 reg(&wlEventLoopGetFD, "wl_event_loop_get_fd")
67+ reg(&wlEventLoopAddFD, "wl_event_loop_add_fd")
68+ reg(&wlEventSourceRemove, "wl_event_source_remove")
64 reg(&wlGlobalCreate, "wl_global_create")69 reg(&wlGlobalCreate, "wl_global_create")
65 reg(&wlResourceCreate, "wl_resource_create")70 reg(&wlResourceCreate, "wl_resource_create")
66 reg(&wlResourceSetDispatcher, "wl_resource_set_dispatcher")71 reg(&wlResourceSetDispatcher, "wl_resource_set_dispatcher")
@@ -74,6 +79,7 @@ func load() error {
74 cbDispatcher = purego.NewCallback(dispatch)79 cbDispatcher = purego.NewCallback(dispatch)
75 cbDestroy = purego.NewCallback(destroyed)80 cbDestroy = purego.NewCallback(destroyed)
76 cbBind = purego.NewCallback(bind)81 cbBind = purego.NewCallback(bind)
82+ cbWake = purego.NewCallback(wake)
77 })83 })
78 return loadErr84 return loadErr
79 }85 }

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 14

↑↓ moveenter openesc close