detached 58529e2

feat(server): add non-blocking Display.Post and Display.AddFD

commit
58529e2232732819843456def3400528a008d569
author
brice · · 2026-10-13 15:29 UTC
parent
2e85f57
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +579 -14
M server/display.go +190 -14
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "runtime"
88 "strings"
9+ "sync"
910 "sync/atomic"
1011 "unsafe"
1112
@@ -48,15 +49,18 @@ var live = struct {
4849 nextBind uintptr
4950 // clientGone maps wl_listener addresses to OnDestroy functions.
5051 clientGone map[uintptr]func()
51-}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}}
52+ // fds maps AddFD tokens to their readable callbacks.
53+ fds map[uintptr]func()
54+ nextFD uintptr
55+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}, fds: map[uintptr]func(){}}
5256
5357 // Display owns a wl_display. All methods except Do must run on the goroutine
5458 // that calls Run, or before Run starts.
5559 //
5660 // Resource, bind and client-listener state is shared by the whole process, so
5761 // only one Display may be running at a time per process.
58-// Do wakes the loop through an eventfd so calls run at once instead of after
59-// the dispatch timeout.
62+// Do and Post wake the loop through an eventfd so calls run at once instead of
63+// after the dispatch timeout.
6064 type Display struct {
6165 c uintptr
6266 loop uintptr
@@ -67,8 +71,29 @@ type Display struct {
6771 wakeSrc uintptr
6872 stopped chan struct{}
6973 state atomic.Uint32
74+
75+ // postMu guards posts and closed. spare is only used by the display
76+ // goroutine: it is the emptied batch swapped back in on the next drain, so
77+ // steady-state Post does not allocate.
78+ postMu sync.Mutex
79+ posts []func()
80+ spare []func()
81+ // armed is true while an eventfd wake for posts is pending, so a burst of
82+ // Posts writes the eventfd once.
83+ armed atomic.Bool
84+ closed atomic.Bool // set under postMu before the wake fd is closed
85+ // wakeWrites counts eventfd writes made for posts (diagnostics and tests).
86+ wakeWrites atomic.Uint64
87+
88+ // sources are the AddFD sources not yet removed (display goroutine only).
89+ sources map[*Source]struct{}
7090 }
7191
92+// maxDrainRounds bounds how many times one loop iteration re-drains calls and
93+// posts that were queued by callbacks, so a self-posting callback cannot starve
94+// libwayland's dispatch and client flushing.
95+const maxDrainRounds = 8
96+
7297 func NewDisplay() (*Display, error) {
7398 if err := load(); err != nil {
7499 return nil, err
@@ -82,7 +107,7 @@ func NewDisplay() (*Display, error) {
82107 wlDisplayDestroy(c)
83108 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
84109 }
85- d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{})}
110+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}}
86111 // The callback only drains the counter; Run then executes queued calls.
87112 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
88113 unix.Close(wake)
@@ -163,6 +188,16 @@ func (d *Display) Close() {
163188
164189 // destroy frees the display and the wake-up fds.
165190 func (d *Display) destroy() {
191+ // Stop accepting posts before the wake fd is closed (its number could be
192+ // reused), and drop what never ran.
193+ d.postMu.Lock()
194+ d.closed.Store(true)
195+ clear(d.posts)
196+ d.posts, d.spare = nil, nil
197+ d.postMu.Unlock()
198+ for s := range d.sources {
199+ s.remove()
200+ }
166201 wlDisplayDestroyClients(d.c)
167202 wlEventSourceRemove(d.wakeSrc)
168203 wlDisplayDestroy(d.c)
@@ -183,16 +218,8 @@ func (d *Display) Run(ctx context.Context) error {
183218 if ctx.Err() != nil {
184219 return nil
185220 }
186- // Run every queued call; each Do also wrote the eventfd.
187- drain:
188- for {
189- select {
190- case fn := <-d.calls:
191- fn()
192- default:
193- break drain
194- }
195- }
221+ // Run every queued post and call; each also wrote the eventfd.
222+ d.drain()
196223 wlDisplayFlushClients(d.c)
197224 // The timeout only bounds how late ctx cancellation is seen.
198225 if wlEventLoopDispatch(d.loop, 100) < 0 {
@@ -209,6 +236,147 @@ func (d *Display) Run(ctx context.Context) error {
209236 }
210237 }
211238
239+// drain runs queued posts and Do calls until none are left, at most
240+// maxDrainRounds rounds. Posts are taken again after each call is received and
241+// before it runs, so a Post that happened before a Do always runs before that
242+// Do's function. When the cap is hit the loop is woken again so the next
243+// dispatch returns at once.
244+func (d *Display) drain() {
245+ for range maxDrainRounds {
246+ ran := d.runPosts()
247+ calls:
248+ for {
249+ select {
250+ case fn := <-d.calls:
251+ d.runPosts()
252+ fn()
253+ ran = true
254+ default:
255+ break calls
256+ }
257+ }
258+ if !ran {
259+ return
260+ }
261+ }
262+ d.armed.Store(true)
263+ d.wakeLoop()
264+}
265+
266+// runPosts runs the posts queued so far and reports whether there were any.
267+func (d *Display) runPosts() bool {
268+ // Clear armed before taking the batch: a Post that lands after the swap
269+ // sees armed false and writes the eventfd again.
270+ d.armed.Store(false)
271+ d.postMu.Lock()
272+ batch := d.posts
273+ d.posts, d.spare = d.spare, nil
274+ d.postMu.Unlock()
275+ for _, fn := range batch {
276+ fn()
277+ }
278+ clear(batch)
279+ d.spare = batch[:0]
280+ return len(batch) > 0
281+}
282+
283+// wakeOne is the eventfd increment. It is read-only, so sharing it between
284+// goroutines is safe and avoids a per-call buffer (which escapes under -race).
285+var wakeOne = [8]byte{1}
286+
287+// wakeLoop writes the eventfd on behalf of posts.
288+func (d *Display) wakeLoop() {
289+ d.wakeWrites.Add(1)
290+ _, _ = unix.Write(d.wake, wakeOne[:])
291+}
292+
293+// Post queues fn to run on the display goroutine and returns at once. Unlike
294+// Do it never waits, and unlike Do it may be called from any goroutine,
295+// including the display goroutine itself.
296+//
297+// Posts run in the order they were made, and a Post made before a Do (by the
298+// same goroutine, or otherwise known to happen before it) runs before that Do's
299+// function. A Post made by a posted function or a Do function runs in a later
300+// drain round of the same loop iteration (at most 8 rounds; past that the loop
301+// is woken again and continues after one libwayland dispatch). Wakes are coalesced:
302+// any number of Posts while one wake is pending write the eventfd once.
303+//
304+// fn must not block: it runs on the goroutine that serves every client. After
305+// the display has stopped or been closed, Post silently drops fn, and posts
306+// that had not run when the display stopped are dropped too. In steady state
307+// Post does not allocate.
308+func (d *Display) Post(fn func()) {
309+ if fn == nil {
310+ return
311+ }
312+ d.postMu.Lock()
313+ defer d.postMu.Unlock()
314+ if d.closed.Load() {
315+ return
316+ }
317+ d.posts = append(d.posts, fn)
318+ // Written under postMu so destroy cannot close the fd underneath.
319+ if d.armed.CompareAndSwap(false, true) {
320+ d.wakeLoop()
321+ }
322+}
323+
324+// Source is an fd registered with the display's event loop by AddFD.
325+type Source struct {
326+ d *Display
327+ c uintptr
328+ token uintptr
329+}
330+
331+// AddFD calls onReadable on the display goroutine whenever fd is readable. The
332+// registration is level-triggered: onReadable must consume the readiness (read
333+// the eventfd or timerfd, accept, ...) or it is called again at once. It
334+// should not block.
335+//
336+// libwayland duplicates fd; the caller still owns and must close its own
337+// descriptor, and should do so after Remove. Call AddFD only on the display
338+// goroutine or before Run starts. Sources not removed are released when the
339+// display is destroyed.
340+func (d *Display) AddFD(fd int, onReadable func()) (*Source, error) {
341+ if fd < 0 {
342+ return nil, fmt.Errorf("purego-libwayland: AddFD: invalid fd %d", fd)
343+ }
344+ if onReadable == nil {
345+ return nil, errors.New("purego-libwayland: AddFD: nil callback")
346+ }
347+ if d.closed.Load() {
348+ return nil, errors.New("purego-libwayland: AddFD: display closed")
349+ }
350+ live.nextFD++
351+ token := live.nextFD
352+ live.fds[token] = onReadable
353+ c := wlEventLoopAddFD(d.loop, int32(fd), 1 /* WL_EVENT_READABLE */, cbFD, token)
354+ if c == 0 {
355+ delete(live.fds, token)
356+ return nil, fmt.Errorf("purego-libwayland: wl_event_loop_add_fd(%d) failed", fd)
357+ }
358+ s := &Source{d: d, c: c, token: token}
359+ d.sources[s] = struct{}{}
360+ return s, nil
361+}
362+
363+// Remove stops the callbacks and releases libwayland's duplicate of the fd.
364+// It may be called from the source's own callback. Display goroutine only;
365+// idempotent, and a no-op after the display was destroyed.
366+func (s *Source) Remove() {
367+ if s == nil || s.c == 0 {
368+ return
369+ }
370+ delete(s.d.sources, s)
371+ s.remove()
372+}
373+
374+func (s *Source) remove() {
375+ delete(live.fds, s.token)
376+ wlEventSourceRemove(s.c)
377+ s.c = 0
378+}
379+
212380 // Do runs fn on the display goroutine and waits for it. It returns false if
213381 // the display loop has stopped (fn was not run, or its run was not confirmed).
214382 // Do must not be called from the display goroutine: it would deadlock.
@@ -418,6 +586,14 @@ func wake(fd int32, mask uint32, data uintptr) int32 {
418586 return 0
419587 }
420588
589+// fdReadable dispatches an AddFD source (wl_event_loop_fd_func_t).
590+func fdReadable(_ int32, _ uint32, data uintptr) int32 {
591+ if fn := live.fds[data]; fn != nil {
592+ fn()
593+ }
594+ return 0
595+}
596+
421597 func bind(client uintptr, data uintptr, version uint32, id uint32) {
422598 if fn := live.binds[data]; fn != nil {
423599 fn(Client{client}, version, id)
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "runtime"7 "runtime"
8 "strings"8 "strings"
9+ "sync"
9 "sync/atomic"10 "sync/atomic"
10 "unsafe"11 "unsafe"
11 12
@@ -48,15 +49,18 @@ var live = struct {
48 nextBind uintptr49 nextBind uintptr
49 // clientGone maps wl_listener addresses to OnDestroy functions.50 // clientGone maps wl_listener addresses to OnDestroy functions.
50 clientGone map[uintptr]func()51 clientGone map[uintptr]func()
51-}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}}52+ // fds maps AddFD tokens to their readable callbacks.
53+ fds map[uintptr]func()
54+ nextFD uintptr
55+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}, fds: map[uintptr]func(){}}
52 56
53 // Display owns a wl_display. All methods except Do must run on the goroutine57 // Display owns a wl_display. All methods except Do must run on the goroutine
54 // that calls Run, or before Run starts.58 // that calls Run, or before Run starts.
55 //59 //
56 // Resource, bind and client-listener state is shared by the whole process, so60 // Resource, bind and client-listener state is shared by the whole process, so
57 // only one Display may be running at a time per process.61 // only one Display may be running at a time per process.
58-// Do wakes the loop through an eventfd so calls run at once instead of after62+// Do and Post wake the loop through an eventfd so calls run at once instead of
59-// the dispatch timeout.63+// after the dispatch timeout.
60 type Display struct {64 type Display struct {
61 c uintptr65 c uintptr
62 loop uintptr66 loop uintptr
@@ -67,8 +71,29 @@ type Display struct {
67 wakeSrc uintptr71 wakeSrc uintptr
68 stopped chan struct{}72 stopped chan struct{}
69 state atomic.Uint3273 state atomic.Uint32
74+
75+ // postMu guards posts and closed. spare is only used by the display
76+ // goroutine: it is the emptied batch swapped back in on the next drain, so
77+ // steady-state Post does not allocate.
78+ postMu sync.Mutex
79+ posts []func()
80+ spare []func()
81+ // armed is true while an eventfd wake for posts is pending, so a burst of
82+ // Posts writes the eventfd once.
83+ armed atomic.Bool
84+ closed atomic.Bool // set under postMu before the wake fd is closed
85+ // wakeWrites counts eventfd writes made for posts (diagnostics and tests).
86+ wakeWrites atomic.Uint64
87+
88+ // sources are the AddFD sources not yet removed (display goroutine only).
89+ sources map[*Source]struct{}
70 }90 }
71 91
92+// maxDrainRounds bounds how many times one loop iteration re-drains calls and
93+// posts that were queued by callbacks, so a self-posting callback cannot starve
94+// libwayland's dispatch and client flushing.
95+const maxDrainRounds = 8
96+
72 func NewDisplay() (*Display, error) {97 func NewDisplay() (*Display, error) {
73 if err := load(); err != nil {98 if err := load(); err != nil {
74 return nil, err99 return nil, err
@@ -82,7 +107,7 @@ func NewDisplay() (*Display, error) {
82 wlDisplayDestroy(c)107 wlDisplayDestroy(c)
83 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)108 return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
84 }109 }
85- d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{})}110+ d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}}
86 // The callback only drains the counter; Run then executes queued calls.111 // The callback only drains the counter; Run then executes queued calls.
87 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {112 if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
88 unix.Close(wake)113 unix.Close(wake)
@@ -163,6 +188,16 @@ func (d *Display) Close() {
163 188
164 // destroy frees the display and the wake-up fds.189 // destroy frees the display and the wake-up fds.
165 func (d *Display) destroy() {190 func (d *Display) destroy() {
191+ // Stop accepting posts before the wake fd is closed (its number could be
192+ // reused), and drop what never ran.
193+ d.postMu.Lock()
194+ d.closed.Store(true)
195+ clear(d.posts)
196+ d.posts, d.spare = nil, nil
197+ d.postMu.Unlock()
198+ for s := range d.sources {
199+ s.remove()
200+ }
166 wlDisplayDestroyClients(d.c)201 wlDisplayDestroyClients(d.c)
167 wlEventSourceRemove(d.wakeSrc)202 wlEventSourceRemove(d.wakeSrc)
168 wlDisplayDestroy(d.c)203 wlDisplayDestroy(d.c)
@@ -183,16 +218,8 @@ func (d *Display) Run(ctx context.Context) error {
183 if ctx.Err() != nil {218 if ctx.Err() != nil {
184 return nil219 return nil
185 }220 }
186- // Run every queued call; each Do also wrote the eventfd.221+ // Run every queued post and call; each also wrote the eventfd.
187- drain:222+ d.drain()
188- for {
189- select {
190- case fn := <-d.calls:
191- fn()
192- default:
193- break drain
194- }
195- }
196 wlDisplayFlushClients(d.c)223 wlDisplayFlushClients(d.c)
197 // The timeout only bounds how late ctx cancellation is seen.224 // The timeout only bounds how late ctx cancellation is seen.
198 if wlEventLoopDispatch(d.loop, 100) < 0 {225 if wlEventLoopDispatch(d.loop, 100) < 0 {
@@ -209,6 +236,147 @@ func (d *Display) Run(ctx context.Context) error {
209 }236 }
210 }237 }
211 238
239+// drain runs queued posts and Do calls until none are left, at most
240+// maxDrainRounds rounds. Posts are taken again after each call is received and
241+// before it runs, so a Post that happened before a Do always runs before that
242+// Do's function. When the cap is hit the loop is woken again so the next
243+// dispatch returns at once.
244+func (d *Display) drain() {
245+ for range maxDrainRounds {
246+ ran := d.runPosts()
247+ calls:
248+ for {
249+ select {
250+ case fn := <-d.calls:
251+ d.runPosts()
252+ fn()
253+ ran = true
254+ default:
255+ break calls
256+ }
257+ }
258+ if !ran {
259+ return
260+ }
261+ }
262+ d.armed.Store(true)
263+ d.wakeLoop()
264+}
265+
266+// runPosts runs the posts queued so far and reports whether there were any.
267+func (d *Display) runPosts() bool {
268+ // Clear armed before taking the batch: a Post that lands after the swap
269+ // sees armed false and writes the eventfd again.
270+ d.armed.Store(false)
271+ d.postMu.Lock()
272+ batch := d.posts
273+ d.posts, d.spare = d.spare, nil
274+ d.postMu.Unlock()
275+ for _, fn := range batch {
276+ fn()
277+ }
278+ clear(batch)
279+ d.spare = batch[:0]
280+ return len(batch) > 0
281+}
282+
283+// wakeOne is the eventfd increment. It is read-only, so sharing it between
284+// goroutines is safe and avoids a per-call buffer (which escapes under -race).
285+var wakeOne = [8]byte{1}
286+
287+// wakeLoop writes the eventfd on behalf of posts.
288+func (d *Display) wakeLoop() {
289+ d.wakeWrites.Add(1)
290+ _, _ = unix.Write(d.wake, wakeOne[:])
291+}
292+
293+// Post queues fn to run on the display goroutine and returns at once. Unlike
294+// Do it never waits, and unlike Do it may be called from any goroutine,
295+// including the display goroutine itself.
296+//
297+// Posts run in the order they were made, and a Post made before a Do (by the
298+// same goroutine, or otherwise known to happen before it) runs before that Do's
299+// function. A Post made by a posted function or a Do function runs in a later
300+// drain round of the same loop iteration (at most 8 rounds; past that the loop
301+// is woken again and continues after one libwayland dispatch). Wakes are coalesced:
302+// any number of Posts while one wake is pending write the eventfd once.
303+//
304+// fn must not block: it runs on the goroutine that serves every client. After
305+// the display has stopped or been closed, Post silently drops fn, and posts
306+// that had not run when the display stopped are dropped too. In steady state
307+// Post does not allocate.
308+func (d *Display) Post(fn func()) {
309+ if fn == nil {
310+ return
311+ }
312+ d.postMu.Lock()
313+ defer d.postMu.Unlock()
314+ if d.closed.Load() {
315+ return
316+ }
317+ d.posts = append(d.posts, fn)
318+ // Written under postMu so destroy cannot close the fd underneath.
319+ if d.armed.CompareAndSwap(false, true) {
320+ d.wakeLoop()
321+ }
322+}
323+
324+// Source is an fd registered with the display's event loop by AddFD.
325+type Source struct {
326+ d *Display
327+ c uintptr
328+ token uintptr
329+}
330+
331+// AddFD calls onReadable on the display goroutine whenever fd is readable. The
332+// registration is level-triggered: onReadable must consume the readiness (read
333+// the eventfd or timerfd, accept, ...) or it is called again at once. It
334+// should not block.
335+//
336+// libwayland duplicates fd; the caller still owns and must close its own
337+// descriptor, and should do so after Remove. Call AddFD only on the display
338+// goroutine or before Run starts. Sources not removed are released when the
339+// display is destroyed.
340+func (d *Display) AddFD(fd int, onReadable func()) (*Source, error) {
341+ if fd < 0 {
342+ return nil, fmt.Errorf("purego-libwayland: AddFD: invalid fd %d", fd)
343+ }
344+ if onReadable == nil {
345+ return nil, errors.New("purego-libwayland: AddFD: nil callback")
346+ }
347+ if d.closed.Load() {
348+ return nil, errors.New("purego-libwayland: AddFD: display closed")
349+ }
350+ live.nextFD++
351+ token := live.nextFD
352+ live.fds[token] = onReadable
353+ c := wlEventLoopAddFD(d.loop, int32(fd), 1 /* WL_EVENT_READABLE */, cbFD, token)
354+ if c == 0 {
355+ delete(live.fds, token)
356+ return nil, fmt.Errorf("purego-libwayland: wl_event_loop_add_fd(%d) failed", fd)
357+ }
358+ s := &Source{d: d, c: c, token: token}
359+ d.sources[s] = struct{}{}
360+ return s, nil
361+}
362+
363+// Remove stops the callbacks and releases libwayland's duplicate of the fd.
364+// It may be called from the source's own callback. Display goroutine only;
365+// idempotent, and a no-op after the display was destroyed.
366+func (s *Source) Remove() {
367+ if s == nil || s.c == 0 {
368+ return
369+ }
370+ delete(s.d.sources, s)
371+ s.remove()
372+}
373+
374+func (s *Source) remove() {
375+ delete(live.fds, s.token)
376+ wlEventSourceRemove(s.c)
377+ s.c = 0
378+}
379+
212 // Do runs fn on the display goroutine and waits for it. It returns false if380 // Do runs fn on the display goroutine and waits for it. It returns false if
213 // the display loop has stopped (fn was not run, or its run was not confirmed).381 // the display loop has stopped (fn was not run, or its run was not confirmed).
214 // Do must not be called from the display goroutine: it would deadlock.382 // Do must not be called from the display goroutine: it would deadlock.
@@ -418,6 +586,14 @@ func wake(fd int32, mask uint32, data uintptr) int32 {
418 return 0586 return 0
419 }587 }
420 588
589+// fdReadable dispatches an AddFD source (wl_event_loop_fd_func_t).
590+func fdReadable(_ int32, _ uint32, data uintptr) int32 {
591+ if fn := live.fds[data]; fn != nil {
592+ fn()
593+ }
594+ return 0
595+}
596+
421 func bind(client uintptr, data uintptr, version uint32, id uint32) {597 func bind(client uintptr, data uintptr, version uint32, id uint32) {
422 if fn := live.binds[data]; fn != nil {598 if fn := live.binds[data]; fn != nil {
423 fn(Client{client}, version, id)599 fn(Client{client}, version, id)
M server/lib.go +4 -0
@@ -43,6 +43,7 @@ var (
4343 cbClientGone uintptr
4444 cbBind uintptr
4545 cbWake uintptr
46+ cbFD uintptr
4647 )
4748
4849 func load() error {
@@ -127,6 +128,9 @@ func load() error {
127128 cbWake = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
128129 return uintptr(uint32(wake(int32(a.Int(0)), uint32(a.Int(1)), a.Int(2))))
129130 })
131+ cbFD = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
132+ return uintptr(uint32(fdReadable(int32(a.Int(0)), uint32(a.Int(1)), a.Int(2))))
133+ })
130134 })
131135 return loadErr
132136 }
@@ -43,6 +43,7 @@ var (
43 cbClientGone uintptr43 cbClientGone uintptr
44 cbBind uintptr44 cbBind uintptr
45 cbWake uintptr45 cbWake uintptr
46+ cbFD uintptr
46 )47 )
47 48
48 func load() error {49 func load() error {
@@ -127,6 +128,9 @@ func load() error {
127 cbWake = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {128 cbWake = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
128 return uintptr(uint32(wake(int32(a.Int(0)), uint32(a.Int(1)), a.Int(2))))129 return uintptr(uint32(wake(int32(a.Int(0)), uint32(a.Int(1)), a.Int(2))))
129 })130 })
131+ cbFD = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
132+ return uintptr(uint32(fdReadable(int32(a.Int(0)), uint32(a.Int(1)), a.Int(2))))
133+ })
130 })134 })
131 return loadErr135 return loadErr
132 }136 }
A server/post_test.go +385 -0
@@ -0,0 +1,385 @@
1+package server
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// runDisplay starts a display and stops it at test cleanup.
13+func runDisplay(t *testing.T) *Display {
14+ t.Helper()
15+ d, err := NewDisplay()
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ ctx, cancel := context.WithCancel(context.Background())
20+ done := make(chan error, 1)
21+ go func() { done <- d.Run(ctx) }()
22+ t.Cleanup(func() {
23+ cancel()
24+ if err := <-done; err != nil {
25+ t.Error(err)
26+ }
27+ })
28+ return d
29+}
30+
31+func waitFor(t *testing.T, ch <-chan struct{}, what string) {
32+ t.Helper()
33+ select {
34+ case <-ch:
35+ case <-time.After(3 * time.Second):
36+ t.Fatalf("timeout waiting for %s", what)
37+ }
38+}
39+
40+// displayTID reports the thread the display goroutine is locked to.
41+func displayTID(t *testing.T, d *Display) int {
42+ t.Helper()
43+ tid := 0
44+ if !d.Do(func() { tid = unix.Gettid() }) {
45+ t.Fatal("display stopped")
46+ }
47+ return tid
48+}
49+
50+func TestPostRunsOnDisplayGoroutine(t *testing.T) {
51+ d := runDisplay(t)
52+ want := displayTID(t, d)
53+ got := make(chan int, 1)
54+ go d.Post(func() { got <- unix.Gettid() })
55+ select {
56+ case tid := <-got:
57+ if tid != want {
58+ t.Fatalf("post ran on thread %d, display thread is %d", tid, want)
59+ }
60+ case <-time.After(3 * time.Second):
61+ t.Fatal("post did not run")
62+ }
63+}
64+
65+func TestPostFIFOWithDo(t *testing.T) {
66+ d := runDisplay(t)
67+ var order []int
68+ for i := range 200 {
69+ d.Post(func() { order = append(order, 2*i) })
70+ if !d.Do(func() { order = append(order, 2*i+1) }) {
71+ t.Fatal("display stopped")
72+ }
73+ }
74+ // Do returned, so the display goroutine is done with order.
75+ for i, v := range order {
76+ if v != i {
77+ t.Fatalf("order[%d] = %d, want %d (%v)", i, v, i, order[:i+1])
78+ }
79+ }
80+ if len(order) != 400 {
81+ t.Fatalf("%d callbacks ran, want 400", len(order))
82+ }
83+}
84+
85+func TestPostFIFOOrder(t *testing.T) {
86+ d := runDisplay(t)
87+ var order []int
88+ for i := range 1000 {
89+ d.Post(func() { order = append(order, i) })
90+ }
91+ d.Do(func() {})
92+ for i, v := range order {
93+ if v != i {
94+ t.Fatalf("order[%d] = %d", i, v)
95+ }
96+ }
97+ if len(order) != 1000 {
98+ t.Fatalf("%d posts ran, want 1000", len(order))
99+ }
100+}
101+
102+func TestPostCoalescesWakes(t *testing.T) {
103+ d := runDisplay(t)
104+ entered, release := make(chan struct{}), make(chan struct{})
105+ doDone := make(chan struct{})
106+ // Hold the display goroutine so every Post lands while one wake is pending.
107+ go func() { d.Do(func() { close(entered); <-release }); close(doDone) }()
108+ waitFor(t, entered, "display busy")
109+ before := d.wakeWrites.Load()
110+ var mu sync.Mutex
111+ ran := 0
112+ for range 1000 {
113+ d.Post(func() { mu.Lock(); ran++; mu.Unlock() })
114+ }
115+ if w := d.wakeWrites.Load() - before; w > 2 {
116+ t.Fatalf("1000 posts wrote the eventfd %d times", w)
117+ }
118+ close(release)
119+ waitFor(t, doDone, "Do")
120+ d.Do(func() {})
121+ mu.Lock()
122+ defer mu.Unlock()
123+ if ran != 1000 {
124+ t.Fatalf("%d posts ran, want 1000", ran)
125+ }
126+}
127+
128+func TestPostAfterStopDropped(t *testing.T) {
129+ d, err := NewDisplay()
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ ctx, cancel := context.WithCancel(context.Background())
134+ done := make(chan error, 1)
135+ go func() { done <- d.Run(ctx) }()
136+ d.Do(func() {})
137+ cancel()
138+ if err := <-done; err != nil {
139+ t.Fatal(err)
140+ }
141+ <-d.Stopped()
142+ d.Post(func() { t.Error("post ran after stop") })
143+ time.Sleep(20 * time.Millisecond)
144+
145+ // A display closed before Run drops posts too.
146+ c, err := NewDisplay()
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ c.Close()
151+ c.Post(func() { t.Error("post ran after close") })
152+ c.Post(nil)
153+}
154+
155+func TestPostFromPostedFunc(t *testing.T) {
156+ d := runDisplay(t)
157+ var order []string
158+ done := make(chan struct{})
159+ d.Post(func() {
160+ order = append(order, "outer")
161+ d.Post(func() {
162+ order = append(order, "inner")
163+ close(done)
164+ })
165+ order = append(order, "outer-end")
166+ })
167+ waitFor(t, done, "nested post")
168+ d.Do(func() {})
169+ want := []string{"outer", "outer-end", "inner"}
170+ if len(order) != len(want) {
171+ t.Fatalf("order %v", order)
172+ }
173+ for i := range want {
174+ if order[i] != want[i] {
175+ t.Fatalf("order %v, want %v", order, want)
176+ }
177+ }
178+}
179+
180+// A callback that keeps posting must not stop libwayland from dispatching, and
181+// the capped rounds must be resumed by a wake instead of waiting for the
182+// dispatch timeout.
183+func TestPostSelfRepostingIsCappedAndResumes(t *testing.T) {
184+ d := runDisplay(t)
185+ const total = 100 // > maxDrainRounds, so the cap is hit
186+ var n int
187+ done := make(chan struct{})
188+ var step func()
189+ step = func() {
190+ n++
191+ if n == total {
192+ close(done)
193+ return
194+ }
195+ d.Post(step)
196+ }
197+ start := time.Now()
198+ d.Post(step)
199+ waitFor(t, done, "self-reposting chain")
200+ if e := time.Since(start); e > 50*time.Millisecond {
201+ t.Fatalf("chain of %d posts took %v; wake not re-armed at the round cap", total, e)
202+ }
203+ // Do still gets served.
204+ if !d.Do(func() {}) {
205+ t.Fatal("Do failed")
206+ }
207+}
208+
209+func TestPostAllocations(t *testing.T) {
210+ d, err := NewDisplay()
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ defer d.Close()
215+ n := 0
216+ fn := func() { n++ }
217+ // Warm up both batch buffers, then drain on this goroutine (not running).
218+ for range 4 {
219+ d.Post(fn)
220+ d.Post(fn)
221+ d.drain()
222+ }
223+ if allocs := testing.AllocsPerRun(1000, func() {
224+ d.Post(fn)
225+ d.Post(fn)
226+ d.drain()
227+ }); allocs > 0 {
228+ t.Fatalf("Post+drain allocates %v per run", allocs)
229+ }
230+ if n == 0 {
231+ t.Fatal("posts did not run")
232+ }
233+}
234+
235+func TestAddFDEventfdOnDisplayGoroutine(t *testing.T) {
236+ d := runDisplay(t)
237+ want := displayTID(t, d)
238+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ defer unix.Close(efd)
243+ got := make(chan int, 4)
244+ var src *Source
245+ if !d.Do(func() {
246+ src, err = d.AddFD(efd, func() {
247+ var b [8]byte
248+ _, _ = unix.Read(efd, b[:])
249+ got <- unix.Gettid()
250+ })
251+ }) || err != nil {
252+ t.Fatalf("AddFD: %v", err)
253+ }
254+ one := [8]byte{1}
255+ if _, err := unix.Write(efd, one[:]); err != nil {
256+ t.Fatal(err)
257+ }
258+ select {
259+ case tid := <-got:
260+ if tid != want {
261+ t.Fatalf("callback on thread %d, display thread is %d", tid, want)
262+ }
263+ case <-time.After(3 * time.Second):
264+ t.Fatal("callback did not fire")
265+ }
266+
267+ // Remove stops callbacks; it is idempotent.
268+ d.Do(func() { src.Remove(); src.Remove() })
269+ if _, err := unix.Write(efd, one[:]); err != nil {
270+ t.Fatal(err)
271+ }
272+ d.Do(func() {})
273+ time.Sleep(30 * time.Millisecond)
274+ select {
275+ case <-got:
276+ t.Fatal("callback fired after Remove")
277+ default:
278+ }
279+ d.Do(func() {
280+ if len(live.fds) != 0 {
281+ t.Errorf("%d fd callbacks leaked", len(live.fds))
282+ }
283+ })
284+}
285+
286+func TestAddFDTimerfdFiresOnce(t *testing.T) {
287+ d := runDisplay(t)
288+ tfd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
289+ if err != nil {
290+ t.Skip(err)
291+ }
292+ defer unix.Close(tfd)
293+ fired := make(chan struct{}, 8)
294+ if !d.Do(func() {
295+ _, err = d.AddFD(tfd, func() {
296+ var b [8]byte
297+ _, _ = unix.Read(tfd, b[:])
298+ fired <- struct{}{}
299+ })
300+ }) || err != nil {
301+ t.Fatalf("AddFD: %v", err)
302+ }
303+ start := time.Now()
304+ if err := unix.TimerfdSettime(tfd, 0, &unix.ItimerSpec{Value: unix.NsecToTimespec(int64(time.Millisecond))}, nil); err != nil {
305+ t.Fatal(err)
306+ }
307+ waitFor(t, fired, "timerfd")
308+ if e := time.Since(start); e > 50*time.Millisecond {
309+ t.Fatalf("1 ms timer fired after %v", e)
310+ }
311+ time.Sleep(50 * time.Millisecond)
312+ if len(fired) != 0 {
313+ t.Fatalf("one-shot timer fired %d more times", len(fired))
314+ }
315+}
316+
317+func TestAddFDBeforeRunAndRemoveInCallback(t *testing.T) {
318+ d, err := NewDisplay()
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
323+ if err != nil {
324+ t.Fatal(err)
325+ }
326+ defer unix.Close(efd)
327+ fired := make(chan struct{}, 4)
328+ var src *Source
329+ src, err = d.AddFD(efd, func() {
330+ var b [8]byte
331+ _, _ = unix.Read(efd, b[:])
332+ src.Remove() // from its own callback
333+ fired <- struct{}{}
334+ })
335+ if err != nil {
336+ t.Fatal(err)
337+ }
338+ ctx, cancel := context.WithCancel(context.Background())
339+ done := make(chan error, 1)
340+ go func() { done <- d.Run(ctx) }()
341+ defer func() { cancel(); <-done }()
342+ one := [8]byte{1}
343+ for range 2 {
344+ if _, err := unix.Write(efd, one[:]); err != nil {
345+ t.Fatal(err)
346+ }
347+ time.Sleep(20 * time.Millisecond)
348+ }
349+ waitFor(t, fired, "callback")
350+ if len(fired) != 0 {
351+ t.Fatal("callback fired again after removing itself")
352+ }
353+}
354+
355+func TestAddFDErrors(t *testing.T) {
356+ d, err := NewDisplay()
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ if _, err := d.AddFD(-1, func() {}); err == nil {
361+ t.Error("negative fd accepted")
362+ }
363+ if _, err := d.AddFD(0, nil); err == nil {
364+ t.Error("nil callback accepted")
365+ }
366+ // A source left registered is freed with the display; Remove is then a no-op.
367+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ defer unix.Close(efd)
372+ src, err := d.AddFD(efd, func() {})
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ d.Close()
377+ src.Remove()
378+ (*Source)(nil).Remove()
379+ if _, err := d.AddFD(efd, func() {}); err == nil {
380+ t.Error("AddFD accepted on a closed display")
381+ }
382+ if len(live.fds) != 0 {
383+ t.Errorf("%d fd callbacks leaked", len(live.fds))
384+ }
385+}
@@ -0,0 +1,385 @@
1+package server
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// runDisplay starts a display and stops it at test cleanup.
13+func runDisplay(t *testing.T) *Display {
14+ t.Helper()
15+ d, err := NewDisplay()
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ ctx, cancel := context.WithCancel(context.Background())
20+ done := make(chan error, 1)
21+ go func() { done <- d.Run(ctx) }()
22+ t.Cleanup(func() {
23+ cancel()
24+ if err := <-done; err != nil {
25+ t.Error(err)
26+ }
27+ })
28+ return d
29+}
30+
31+func waitFor(t *testing.T, ch <-chan struct{}, what string) {
32+ t.Helper()
33+ select {
34+ case <-ch:
35+ case <-time.After(3 * time.Second):
36+ t.Fatalf("timeout waiting for %s", what)
37+ }
38+}
39+
40+// displayTID reports the thread the display goroutine is locked to.
41+func displayTID(t *testing.T, d *Display) int {
42+ t.Helper()
43+ tid := 0
44+ if !d.Do(func() { tid = unix.Gettid() }) {
45+ t.Fatal("display stopped")
46+ }
47+ return tid
48+}
49+
50+func TestPostRunsOnDisplayGoroutine(t *testing.T) {
51+ d := runDisplay(t)
52+ want := displayTID(t, d)
53+ got := make(chan int, 1)
54+ go d.Post(func() { got <- unix.Gettid() })
55+ select {
56+ case tid := <-got:
57+ if tid != want {
58+ t.Fatalf("post ran on thread %d, display thread is %d", tid, want)
59+ }
60+ case <-time.After(3 * time.Second):
61+ t.Fatal("post did not run")
62+ }
63+}
64+
65+func TestPostFIFOWithDo(t *testing.T) {
66+ d := runDisplay(t)
67+ var order []int
68+ for i := range 200 {
69+ d.Post(func() { order = append(order, 2*i) })
70+ if !d.Do(func() { order = append(order, 2*i+1) }) {
71+ t.Fatal("display stopped")
72+ }
73+ }
74+ // Do returned, so the display goroutine is done with order.
75+ for i, v := range order {
76+ if v != i {
77+ t.Fatalf("order[%d] = %d, want %d (%v)", i, v, i, order[:i+1])
78+ }
79+ }
80+ if len(order) != 400 {
81+ t.Fatalf("%d callbacks ran, want 400", len(order))
82+ }
83+}
84+
85+func TestPostFIFOOrder(t *testing.T) {
86+ d := runDisplay(t)
87+ var order []int
88+ for i := range 1000 {
89+ d.Post(func() { order = append(order, i) })
90+ }
91+ d.Do(func() {})
92+ for i, v := range order {
93+ if v != i {
94+ t.Fatalf("order[%d] = %d", i, v)
95+ }
96+ }
97+ if len(order) != 1000 {
98+ t.Fatalf("%d posts ran, want 1000", len(order))
99+ }
100+}
101+
102+func TestPostCoalescesWakes(t *testing.T) {
103+ d := runDisplay(t)
104+ entered, release := make(chan struct{}), make(chan struct{})
105+ doDone := make(chan struct{})
106+ // Hold the display goroutine so every Post lands while one wake is pending.
107+ go func() { d.Do(func() { close(entered); <-release }); close(doDone) }()
108+ waitFor(t, entered, "display busy")
109+ before := d.wakeWrites.Load()
110+ var mu sync.Mutex
111+ ran := 0
112+ for range 1000 {
113+ d.Post(func() { mu.Lock(); ran++; mu.Unlock() })
114+ }
115+ if w := d.wakeWrites.Load() - before; w > 2 {
116+ t.Fatalf("1000 posts wrote the eventfd %d times", w)
117+ }
118+ close(release)
119+ waitFor(t, doDone, "Do")
120+ d.Do(func() {})
121+ mu.Lock()
122+ defer mu.Unlock()
123+ if ran != 1000 {
124+ t.Fatalf("%d posts ran, want 1000", ran)
125+ }
126+}
127+
128+func TestPostAfterStopDropped(t *testing.T) {
129+ d, err := NewDisplay()
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ ctx, cancel := context.WithCancel(context.Background())
134+ done := make(chan error, 1)
135+ go func() { done <- d.Run(ctx) }()
136+ d.Do(func() {})
137+ cancel()
138+ if err := <-done; err != nil {
139+ t.Fatal(err)
140+ }
141+ <-d.Stopped()
142+ d.Post(func() { t.Error("post ran after stop") })
143+ time.Sleep(20 * time.Millisecond)
144+
145+ // A display closed before Run drops posts too.
146+ c, err := NewDisplay()
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ c.Close()
151+ c.Post(func() { t.Error("post ran after close") })
152+ c.Post(nil)
153+}
154+
155+func TestPostFromPostedFunc(t *testing.T) {
156+ d := runDisplay(t)
157+ var order []string
158+ done := make(chan struct{})
159+ d.Post(func() {
160+ order = append(order, "outer")
161+ d.Post(func() {
162+ order = append(order, "inner")
163+ close(done)
164+ })
165+ order = append(order, "outer-end")
166+ })
167+ waitFor(t, done, "nested post")
168+ d.Do(func() {})
169+ want := []string{"outer", "outer-end", "inner"}
170+ if len(order) != len(want) {
171+ t.Fatalf("order %v", order)
172+ }
173+ for i := range want {
174+ if order[i] != want[i] {
175+ t.Fatalf("order %v, want %v", order, want)
176+ }
177+ }
178+}
179+
180+// A callback that keeps posting must not stop libwayland from dispatching, and
181+// the capped rounds must be resumed by a wake instead of waiting for the
182+// dispatch timeout.
183+func TestPostSelfRepostingIsCappedAndResumes(t *testing.T) {
184+ d := runDisplay(t)
185+ const total = 100 // > maxDrainRounds, so the cap is hit
186+ var n int
187+ done := make(chan struct{})
188+ var step func()
189+ step = func() {
190+ n++
191+ if n == total {
192+ close(done)
193+ return
194+ }
195+ d.Post(step)
196+ }
197+ start := time.Now()
198+ d.Post(step)
199+ waitFor(t, done, "self-reposting chain")
200+ if e := time.Since(start); e > 50*time.Millisecond {
201+ t.Fatalf("chain of %d posts took %v; wake not re-armed at the round cap", total, e)
202+ }
203+ // Do still gets served.
204+ if !d.Do(func() {}) {
205+ t.Fatal("Do failed")
206+ }
207+}
208+
209+func TestPostAllocations(t *testing.T) {
210+ d, err := NewDisplay()
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ defer d.Close()
215+ n := 0
216+ fn := func() { n++ }
217+ // Warm up both batch buffers, then drain on this goroutine (not running).
218+ for range 4 {
219+ d.Post(fn)
220+ d.Post(fn)
221+ d.drain()
222+ }
223+ if allocs := testing.AllocsPerRun(1000, func() {
224+ d.Post(fn)
225+ d.Post(fn)
226+ d.drain()
227+ }); allocs > 0 {
228+ t.Fatalf("Post+drain allocates %v per run", allocs)
229+ }
230+ if n == 0 {
231+ t.Fatal("posts did not run")
232+ }
233+}
234+
235+func TestAddFDEventfdOnDisplayGoroutine(t *testing.T) {
236+ d := runDisplay(t)
237+ want := displayTID(t, d)
238+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ defer unix.Close(efd)
243+ got := make(chan int, 4)
244+ var src *Source
245+ if !d.Do(func() {
246+ src, err = d.AddFD(efd, func() {
247+ var b [8]byte
248+ _, _ = unix.Read(efd, b[:])
249+ got <- unix.Gettid()
250+ })
251+ }) || err != nil {
252+ t.Fatalf("AddFD: %v", err)
253+ }
254+ one := [8]byte{1}
255+ if _, err := unix.Write(efd, one[:]); err != nil {
256+ t.Fatal(err)
257+ }
258+ select {
259+ case tid := <-got:
260+ if tid != want {
261+ t.Fatalf("callback on thread %d, display thread is %d", tid, want)
262+ }
263+ case <-time.After(3 * time.Second):
264+ t.Fatal("callback did not fire")
265+ }
266+
267+ // Remove stops callbacks; it is idempotent.
268+ d.Do(func() { src.Remove(); src.Remove() })
269+ if _, err := unix.Write(efd, one[:]); err != nil {
270+ t.Fatal(err)
271+ }
272+ d.Do(func() {})
273+ time.Sleep(30 * time.Millisecond)
274+ select {
275+ case <-got:
276+ t.Fatal("callback fired after Remove")
277+ default:
278+ }
279+ d.Do(func() {
280+ if len(live.fds) != 0 {
281+ t.Errorf("%d fd callbacks leaked", len(live.fds))
282+ }
283+ })
284+}
285+
286+func TestAddFDTimerfdFiresOnce(t *testing.T) {
287+ d := runDisplay(t)
288+ tfd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
289+ if err != nil {
290+ t.Skip(err)
291+ }
292+ defer unix.Close(tfd)
293+ fired := make(chan struct{}, 8)
294+ if !d.Do(func() {
295+ _, err = d.AddFD(tfd, func() {
296+ var b [8]byte
297+ _, _ = unix.Read(tfd, b[:])
298+ fired <- struct{}{}
299+ })
300+ }) || err != nil {
301+ t.Fatalf("AddFD: %v", err)
302+ }
303+ start := time.Now()
304+ if err := unix.TimerfdSettime(tfd, 0, &unix.ItimerSpec{Value: unix.NsecToTimespec(int64(time.Millisecond))}, nil); err != nil {
305+ t.Fatal(err)
306+ }
307+ waitFor(t, fired, "timerfd")
308+ if e := time.Since(start); e > 50*time.Millisecond {
309+ t.Fatalf("1 ms timer fired after %v", e)
310+ }
311+ time.Sleep(50 * time.Millisecond)
312+ if len(fired) != 0 {
313+ t.Fatalf("one-shot timer fired %d more times", len(fired))
314+ }
315+}
316+
317+func TestAddFDBeforeRunAndRemoveInCallback(t *testing.T) {
318+ d, err := NewDisplay()
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
323+ if err != nil {
324+ t.Fatal(err)
325+ }
326+ defer unix.Close(efd)
327+ fired := make(chan struct{}, 4)
328+ var src *Source
329+ src, err = d.AddFD(efd, func() {
330+ var b [8]byte
331+ _, _ = unix.Read(efd, b[:])
332+ src.Remove() // from its own callback
333+ fired <- struct{}{}
334+ })
335+ if err != nil {
336+ t.Fatal(err)
337+ }
338+ ctx, cancel := context.WithCancel(context.Background())
339+ done := make(chan error, 1)
340+ go func() { done <- d.Run(ctx) }()
341+ defer func() { cancel(); <-done }()
342+ one := [8]byte{1}
343+ for range 2 {
344+ if _, err := unix.Write(efd, one[:]); err != nil {
345+ t.Fatal(err)
346+ }
347+ time.Sleep(20 * time.Millisecond)
348+ }
349+ waitFor(t, fired, "callback")
350+ if len(fired) != 0 {
351+ t.Fatal("callback fired again after removing itself")
352+ }
353+}
354+
355+func TestAddFDErrors(t *testing.T) {
356+ d, err := NewDisplay()
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ if _, err := d.AddFD(-1, func() {}); err == nil {
361+ t.Error("negative fd accepted")
362+ }
363+ if _, err := d.AddFD(0, nil); err == nil {
364+ t.Error("nil callback accepted")
365+ }
366+ // A source left registered is freed with the display; Remove is then a no-op.
367+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ defer unix.Close(efd)
372+ src, err := d.AddFD(efd, func() {})
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ d.Close()
377+ src.Remove()
378+ (*Source)(nil).Remove()
379+ if _, err := d.AddFD(efd, func() {}); err == nil {
380+ t.Error("AddFD accepted on a closed display")
381+ }
382+ if len(live.fds) != 0 {
383+ t.Errorf("%d fd callbacks leaked", len(live.fds))
384+ }
385+}

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