detached 634f823

perf(server): wait for the event loop in the Go netpoller

commit
634f823abd229e149fb194bbbeaa1a6a94a7e707
author
brice · · 2026-10-13 19:16 UTC
committer
brice · · 2026-10-13 20:11 UTC
parent
64cf1d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +50 -10
M server/display.go +50 -9
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "os"
78 "runtime"
89 "strings"
910 "sync"
@@ -88,10 +89,6 @@ type Display struct {
8889
8990 // sources are the AddFD sources not yet removed (display goroutine only).
9091 sources map[*Source]struct{}
91-
92- // dispatchMS bounds one event loop wait; it only bounds how late ctx
93- // cancellation is seen. Tests raise it so a lost wake hangs.
94- dispatchMS int32
9592 }
9693
9794 // maxDrainRounds bounds how many times one loop iteration re-drains calls and
@@ -112,7 +109,7 @@ func NewDisplay() (*Display, error) {
112109 wlDisplayDestroy(c)
113110 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
114111 }
115- d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}, dispatchMS: 100}
112+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}}
116113 // The callback only drains the counter; Run then executes queued calls.
117114 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
118115 unix.Close(wake)
@@ -219,21 +216,48 @@ func (d *Display) Run(ctx context.Context) error {
219216 defer runtime.UnlockOSThread()
220217 defer close(d.stopped)
221218 defer d.destroy()
219+ // Wait for the event loop's epoll fd in Go's netpoller instead of blocking
220+ // in epoll_wait inside C: the goroutine parks, its P is released, and the
221+ // runtime's sysmon can sleep deeply while the server is idle. libwayland
222+ // is then dispatched without blocking.
223+ loopFile, err := d.openLoopFile()
224+ if err != nil {
225+ return err
226+ }
227+ defer loopFile.Close() // runs before destroy: the dup must not outlive the epoll fd
228+ rc, err := loopFile.SyscallConn()
229+ if err != nil {
230+ return fmt.Errorf("purego-libwayland: event loop fd: %w", err)
231+ }
232+ // Cancellation wakes the loop through the eventfd.
233+ stop := context.AfterFunc(ctx, d.wakeGuarded)
234+ defer stop()
235+ ready := func(fd uintptr) bool {
236+ // Level-triggered check: the netpoller is edge-triggered, and a
237+ // dispatch can leave events (libwayland takes 32 per epoll_wait).
238+ fds := [1]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
239+ n, err := unix.Poll(fds[:], 0)
240+ return n > 0 || (err != nil && err != unix.EINTR)
241+ }
222242 consecutiveFailures := 0
223243 for {
224244 if ctx.Err() != nil {
225245 return nil
226246 }
227247 // Run every queued post and call. Wakes are coalesced, so the eventfd
228- // is not written once per item; it only makes the dispatch below
248+ // is not written once per item; it only makes the wait below
229249 // return at once when work arrives while the loop is asleep.
230250 d.drain()
231251 wlDisplayFlushClients(d.c)
232- // The timeout only bounds how late ctx cancellation is seen.
233- if wlEventLoopDispatch(d.loop, d.dispatchMS) < 0 {
252+ if err := rc.Read(ready); err != nil {
253+ return fmt.Errorf("purego-libwayland: wait for event loop: %w", err)
254+ }
255+ if ctx.Err() != nil {
256+ return nil
257+ }
258+ if wlEventLoopDispatch(d.loop, 0) < 0 {
234259 errno := lastErrno()
235260 consecutiveFailures++
236- // epoll_wait is interrupted by Go's preemption signals (EINTR); a persistent failure (EBADF, EINVAL) repeats.
237261 if consecutiveFailures < 16 {
238262 continue
239263 }
@@ -244,6 +268,23 @@ func (d *Display) Run(ctx context.Context) error {
244268 }
245269 }
246270
271+// openLoopFile wraps a duplicate of the event loop's epoll fd for the netpoller.
272+func (d *Display) openLoopFile() (*os.File, error) {
273+ fd := int(wlEventLoopGetFD(d.loop))
274+ if fd < 0 {
275+ return nil, errors.New("purego-libwayland: wl_event_loop_get_fd failed")
276+ }
277+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
278+ if err != nil {
279+ return nil, fmt.Errorf("purego-libwayland: dup event loop fd: %w", err)
280+ }
281+ if err := unix.SetNonblock(dup, true); err != nil {
282+ unix.Close(dup)
283+ return nil, fmt.Errorf("purego-libwayland: nonblock event loop fd: %w", err)
284+ }
285+ return os.NewFile(uintptr(dup), "wl-event-loop"), nil
286+}
287+
247288 // drain runs queued posts and Do calls until none are left, at most
248289 // maxDrainRounds rounds. Posts are taken again after each call is received and
249290 // before it runs, so a Post that happened before a Do always runs before that
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "os"
7 "runtime"8 "runtime"
8 "strings"9 "strings"
9 "sync"10 "sync"
@@ -88,10 +89,6 @@ type Display struct {
88 89
89 // sources are the AddFD sources not yet removed (display goroutine only).90 // sources are the AddFD sources not yet removed (display goroutine only).
90 sources map[*Source]struct{}91 sources map[*Source]struct{}
91-
92- // dispatchMS bounds one event loop wait; it only bounds how late ctx
93- // cancellation is seen. Tests raise it so a lost wake hangs.
94- dispatchMS int32
95 }92 }
96 93
97 // maxDrainRounds bounds how many times one loop iteration re-drains calls and94 // maxDrainRounds bounds how many times one loop iteration re-drains calls and
@@ -112,7 +109,7 @@ func NewDisplay() (*Display, error) {
112 wlDisplayDestroy(c)109 wlDisplayDestroy(c)
113 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)110 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
114 }111 }
115- d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}, dispatchMS: 100}112+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}}
116 // The callback only drains the counter; Run then executes queued calls.113 // The callback only drains the counter; Run then executes queued calls.
117 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {114 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
118 unix.Close(wake)115 unix.Close(wake)
@@ -219,21 +216,48 @@ func (d *Display) Run(ctx context.Context) error {
219 defer runtime.UnlockOSThread()216 defer runtime.UnlockOSThread()
220 defer close(d.stopped)217 defer close(d.stopped)
221 defer d.destroy()218 defer d.destroy()
219+ // Wait for the event loop's epoll fd in Go's netpoller instead of blocking
220+ // in epoll_wait inside C: the goroutine parks, its P is released, and the
221+ // runtime's sysmon can sleep deeply while the server is idle. libwayland
222+ // is then dispatched without blocking.
223+ loopFile, err := d.openLoopFile()
224+ if err != nil {
225+ return err
226+ }
227+ defer loopFile.Close() // runs before destroy: the dup must not outlive the epoll fd
228+ rc, err := loopFile.SyscallConn()
229+ if err != nil {
230+ return fmt.Errorf("purego-libwayland: event loop fd: %w", err)
231+ }
232+ // Cancellation wakes the loop through the eventfd.
233+ stop := context.AfterFunc(ctx, d.wakeGuarded)
234+ defer stop()
235+ ready := func(fd uintptr) bool {
236+ // Level-triggered check: the netpoller is edge-triggered, and a
237+ // dispatch can leave events (libwayland takes 32 per epoll_wait).
238+ fds := [1]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
239+ n, err := unix.Poll(fds[:], 0)
240+ return n > 0 || (err != nil && err != unix.EINTR)
241+ }
222 consecutiveFailures := 0242 consecutiveFailures := 0
223 for {243 for {
224 if ctx.Err() != nil {244 if ctx.Err() != nil {
225 return nil245 return nil
226 }246 }
227 // Run every queued post and call. Wakes are coalesced, so the eventfd247 // Run every queued post and call. Wakes are coalesced, so the eventfd
228- // is not written once per item; it only makes the dispatch below248+ // is not written once per item; it only makes the wait below
229 // return at once when work arrives while the loop is asleep.249 // return at once when work arrives while the loop is asleep.
230 d.drain()250 d.drain()
231 wlDisplayFlushClients(d.c)251 wlDisplayFlushClients(d.c)
232- // The timeout only bounds how late ctx cancellation is seen.252+ if err := rc.Read(ready); err != nil {
233- if wlEventLoopDispatch(d.loop, d.dispatchMS) < 0 {253+ return fmt.Errorf("purego-libwayland: wait for event loop: %w", err)
254+ }
255+ if ctx.Err() != nil {
256+ return nil
257+ }
258+ if wlEventLoopDispatch(d.loop, 0) < 0 {
234 errno := lastErrno()259 errno := lastErrno()
235 consecutiveFailures++260 consecutiveFailures++
236- // epoll_wait is interrupted by Go's preemption signals (EINTR); a persistent failure (EBADF, EINVAL) repeats.
237 if consecutiveFailures < 16 {261 if consecutiveFailures < 16 {
238 continue262 continue
239 }263 }
@@ -244,6 +268,23 @@ func (d *Display) Run(ctx context.Context) error {
244 }268 }
245 }269 }
246 270
271+// openLoopFile wraps a duplicate of the event loop's epoll fd for the netpoller.
272+func (d *Display) openLoopFile() (*os.File, error) {
273+ fd := int(wlEventLoopGetFD(d.loop))
274+ if fd < 0 {
275+ return nil, errors.New("purego-libwayland: wl_event_loop_get_fd failed")
276+ }
277+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
278+ if err != nil {
279+ return nil, fmt.Errorf("purego-libwayland: dup event loop fd: %w", err)
280+ }
281+ if err := unix.SetNonblock(dup, true); err != nil {
282+ unix.Close(dup)
283+ return nil, fmt.Errorf("purego-libwayland: nonblock event loop fd: %w", err)
284+ }
285+ return os.NewFile(uintptr(dup), "wl-event-loop"), nil
286+}
287+
247 // drain runs queued posts and Do calls until none are left, at most288 // drain runs queued posts and Do calls until none are left, at most
248 // maxDrainRounds rounds. Posts are taken again after each call is received and289 // maxDrainRounds rounds. Posts are taken again after each call is received and
249 // before it runs, so a Post that happened before a Do always runs before that290 // before it runs, so a Post that happened before a Do always runs before that
M server/post_test.go +0 -1
@@ -395,7 +395,6 @@ func TestPostMultiProducerNoLostWake(t *testing.T) {
395395 if err != nil {
396396 t.Fatal(err)
397397 }
398- d.dispatchMS = 60_000
399398 ctx, cancel := context.WithCancel(context.Background())
400399 runDone := make(chan error, 1)
401400 go func() { runDone <- d.Run(ctx) }()
@@ -395,7 +395,6 @@ func TestPostMultiProducerNoLostWake(t *testing.T) {
395 if err != nil {395 if err != nil {
396 t.Fatal(err)396 t.Fatal(err)
397 }397 }
398- d.dispatchMS = 60_000
399 ctx, cancel := context.WithCancel(context.Background())398 ctx, cancel := context.WithCancel(context.Background())
400 runDone := make(chan error, 1)399 runDone := make(chan error, 1)
401 go func() { runDone <- d.Run(ctx) }()400 go func() { runDone <- d.Run(ctx) }()

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