detached b9309cb

feat(server): add client fd, adoption and destroy listeners

Add Client.FD (wl_client_get_fd), Display.CreateClient (wl_client_create) and Client.OnDestroy (wl_client_add_destroy_listener) with recycled C-visible wl_listener slots.
commit
b9309cb1bc682d8c039d0be82b86b9d217a05e53
author
brice · · 2026-09-30 18:19 UTC
parent
f00b1b1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +506 -3
A server/client.go +131 -0
@@ -0,0 +1,131 @@
1+package server
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "github.com/bnema/purego"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// FD returns the client's connection socket. The descriptor is borrowed from
13+// libwayland: the caller must not close it, and it is valid only until the
14+// client is destroyed (see OnDestroy). Use it to identify or inspect the
15+// connection (for example fstat or /proc/self/fdinfo); reading or writing it
16+// would corrupt the protocol stream. It returns an error for an invalid
17+// (zero) Client. It does not allocate. Display goroutine only.
18+func (c Client) FD() (int, error) {
19+ if c.c == 0 {
20+ return -1, errInvalidClient
21+ }
22+ r, _, _ := purego.Syscall6(symClientGetFD, c.c, 0, 0, 0, 0, 0)
23+ return int(int32(r)), nil
24+}
25+
26+// CreateClient adopts fd, an already connected stream socket (for example one
27+// accepted on a listening socket the compositor owns), as a new client served
28+// by this display.
29+//
30+// Ownership of fd: on success libwayland owns it and closes it when the
31+// client is destroyed; the caller must not close it, nor use it for I/O. On
32+// failure libwayland does not take it (it is left open) and the caller keeps
33+// ownership and must close it. Sockets should be close-on-exec (accept with
34+// SOCK_CLOEXEC); libwayland does not set the flag. fd must be a connected
35+// unix socket: libwayland reads its peer credentials with SO_PEERCRED.
36+//
37+// The returned Client is valid until destroyed; register OnDestroy to learn
38+// when. Display goroutine only, or before Run starts.
39+func (d *Display) CreateClient(fd int) (Client, error) {
40+ if d == nil || d.c == 0 {
41+ return Client{}, errors.New("purego-libwayland: invalid display")
42+ }
43+ if fd < 0 {
44+ return Client{}, fmt.Errorf("purego-libwayland: wl_client_create: invalid fd %d", fd)
45+ }
46+ r, _, _ := purego.Syscall6(symClientCreate, d.c, uintptr(fd), 0, 0, 0, 0)
47+ if r == 0 {
48+ return Client{}, fmt.Errorf("purego-libwayland: wl_client_create(%d) failed; fd not adopted", fd)
49+ }
50+ return Client{r}, nil
51+}
52+
53+// OnDestroy arranges for fn to run once when the client is destroyed, whether
54+// it disconnected, was disconnected after a protocol error, or the display was
55+// closed or stopped. Any number of functions may be registered per client;
56+// each runs once, in registration order. There is no way to unregister one;
57+// make fn a cheap no-op instead if it became irrelevant.
58+//
59+// fn runs on the display goroutine inside libwayland's client destruction. The
60+// Client is still valid during fn (FD and Credentials work, resources are not
61+// yet destroyed) but is dead afterwards: drop every copy of it, for example
62+// delete it from a map[Client]T, because the handle value may be reused by a
63+// later client.
64+//
65+// A Client is a comparable value (a handle to the C object) and is safe as a
66+// map key from its creation or bind until fn runs.
67+//
68+// OnDestroy does nothing for a zero Client or nil fn. Registering on an
69+// already destroyed client is invalid. Display goroutine only, or before Run
70+// starts.
71+func (c Client) OnDestroy(fn func()) {
72+ if c.c == 0 || fn == nil {
73+ return
74+ }
75+ l := listeners.get()
76+ live.clientGone[l] = fn
77+ purego.Syscall6(symClientAddDestroyListener, c.c, l, 0, 0, 0, 0)
78+}
79+
80+// listenerSlots hands out wl_listener structs in C-visible memory that the Go
81+// GC cannot move or free. A slot is a struct wl_listener ({wl_list link;
82+// wl_notify_func_t notify}, 24 bytes on LP64), padded to 32. Slots are
83+// recycled through a free list, so only the peak number of concurrently
84+// registered listeners is ever mapped; the chunks themselves are never
85+// unmapped. Display goroutine only.
86+type listenerSlots struct {
87+ chunks [][]byte
88+ free []uintptr
89+}
90+
91+const (
92+ listenerSlot = 32
93+ listenerChunkSize = 4096
94+)
95+
96+var listeners listenerSlots
97+
98+func (s *listenerSlots) get() uintptr {
99+ if len(s.free) == 0 {
100+ mem, err := unix.Mmap(-1, 0, listenerChunkSize, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS)
101+ if err != nil {
102+ panic(fmt.Errorf("purego-libwayland: listener mmap: %w", err))
103+ }
104+ s.chunks = append(s.chunks, mem)
105+ base := uintptr(unsafe.Pointer(&mem[0]))
106+ for off := listenerChunkSize - listenerSlot; off >= 0; off -= listenerSlot {
107+ s.free = append(s.free, base+uintptr(off))
108+ }
109+ }
110+ l := s.free[len(s.free)-1]
111+ s.free = s.free[:len(s.free)-1]
112+ // notify lives at offset 16; wl_client_add_destroy_listener fills link.
113+ // l is an address inside an mmap chunk kept in s.chunks.
114+ *(*uintptr)(unsafe.Add(*(*unsafe.Pointer)(unsafe.Pointer(&l)), 16)) = cbClientGone
115+ return l
116+}
117+
118+func (s *listenerSlots) put(l uintptr) { s.free = append(s.free, l) }
119+
120+// clientGone is the wl_notify_func_t of every client destroy listener.
121+func clientGone(l uintptr) {
122+ fn := live.clientGone[l]
123+ delete(live.clientGone, l)
124+ // libwayland may leave the node linked while notifying; unlink it
125+ // (harmless if it already is) before the slot can be reused.
126+ purego.Syscall6(symListRemove, l, 0, 0, 0, 0, 0)
127+ listeners.put(l)
128+ if fn != nil {
129+ fn()
130+ }
131+}
@@ -0,0 +1,131 @@
1+package server
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "github.com/bnema/purego"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// FD returns the client's connection socket. The descriptor is borrowed from
13+// libwayland: the caller must not close it, and it is valid only until the
14+// client is destroyed (see OnDestroy). Use it to identify or inspect the
15+// connection (for example fstat or /proc/self/fdinfo); reading or writing it
16+// would corrupt the protocol stream. It returns an error for an invalid
17+// (zero) Client. It does not allocate. Display goroutine only.
18+func (c Client) FD() (int, error) {
19+ if c.c == 0 {
20+ return -1, errInvalidClient
21+ }
22+ r, _, _ := purego.Syscall6(symClientGetFD, c.c, 0, 0, 0, 0, 0)
23+ return int(int32(r)), nil
24+}
25+
26+// CreateClient adopts fd, an already connected stream socket (for example one
27+// accepted on a listening socket the compositor owns), as a new client served
28+// by this display.
29+//
30+// Ownership of fd: on success libwayland owns it and closes it when the
31+// client is destroyed; the caller must not close it, nor use it for I/O. On
32+// failure libwayland does not take it (it is left open) and the caller keeps
33+// ownership and must close it. Sockets should be close-on-exec (accept with
34+// SOCK_CLOEXEC); libwayland does not set the flag. fd must be a connected
35+// unix socket: libwayland reads its peer credentials with SO_PEERCRED.
36+//
37+// The returned Client is valid until destroyed; register OnDestroy to learn
38+// when. Display goroutine only, or before Run starts.
39+func (d *Display) CreateClient(fd int) (Client, error) {
40+ if d == nil || d.c == 0 {
41+ return Client{}, errors.New("purego-libwayland: invalid display")
42+ }
43+ if fd < 0 {
44+ return Client{}, fmt.Errorf("purego-libwayland: wl_client_create: invalid fd %d", fd)
45+ }
46+ r, _, _ := purego.Syscall6(symClientCreate, d.c, uintptr(fd), 0, 0, 0, 0)
47+ if r == 0 {
48+ return Client{}, fmt.Errorf("purego-libwayland: wl_client_create(%d) failed; fd not adopted", fd)
49+ }
50+ return Client{r}, nil
51+}
52+
53+// OnDestroy arranges for fn to run once when the client is destroyed, whether
54+// it disconnected, was disconnected after a protocol error, or the display was
55+// closed or stopped. Any number of functions may be registered per client;
56+// each runs once, in registration order. There is no way to unregister one;
57+// make fn a cheap no-op instead if it became irrelevant.
58+//
59+// fn runs on the display goroutine inside libwayland's client destruction. The
60+// Client is still valid during fn (FD and Credentials work, resources are not
61+// yet destroyed) but is dead afterwards: drop every copy of it, for example
62+// delete it from a map[Client]T, because the handle value may be reused by a
63+// later client.
64+//
65+// A Client is a comparable value (a handle to the C object) and is safe as a
66+// map key from its creation or bind until fn runs.
67+//
68+// OnDestroy does nothing for a zero Client or nil fn. Registering on an
69+// already destroyed client is invalid. Display goroutine only, or before Run
70+// starts.
71+func (c Client) OnDestroy(fn func()) {
72+ if c.c == 0 || fn == nil {
73+ return
74+ }
75+ l := listeners.get()
76+ live.clientGone[l] = fn
77+ purego.Syscall6(symClientAddDestroyListener, c.c, l, 0, 0, 0, 0)
78+}
79+
80+// listenerSlots hands out wl_listener structs in C-visible memory that the Go
81+// GC cannot move or free. A slot is a struct wl_listener ({wl_list link;
82+// wl_notify_func_t notify}, 24 bytes on LP64), padded to 32. Slots are
83+// recycled through a free list, so only the peak number of concurrently
84+// registered listeners is ever mapped; the chunks themselves are never
85+// unmapped. Display goroutine only.
86+type listenerSlots struct {
87+ chunks [][]byte
88+ free []uintptr
89+}
90+
91+const (
92+ listenerSlot = 32
93+ listenerChunkSize = 4096
94+)
95+
96+var listeners listenerSlots
97+
98+func (s *listenerSlots) get() uintptr {
99+ if len(s.free) == 0 {
100+ mem, err := unix.Mmap(-1, 0, listenerChunkSize, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS)
101+ if err != nil {
102+ panic(fmt.Errorf("purego-libwayland: listener mmap: %w", err))
103+ }
104+ s.chunks = append(s.chunks, mem)
105+ base := uintptr(unsafe.Pointer(&mem[0]))
106+ for off := listenerChunkSize - listenerSlot; off >= 0; off -= listenerSlot {
107+ s.free = append(s.free, base+uintptr(off))
108+ }
109+ }
110+ l := s.free[len(s.free)-1]
111+ s.free = s.free[:len(s.free)-1]
112+ // notify lives at offset 16; wl_client_add_destroy_listener fills link.
113+ // l is an address inside an mmap chunk kept in s.chunks.
114+ *(*uintptr)(unsafe.Add(*(*unsafe.Pointer)(unsafe.Pointer(&l)), 16)) = cbClientGone
115+ return l
116+}
117+
118+func (s *listenerSlots) put(l uintptr) { s.free = append(s.free, l) }
119+
120+// clientGone is the wl_notify_func_t of every client destroy listener.
121+func clientGone(l uintptr) {
122+ fn := live.clientGone[l]
123+ delete(live.clientGone, l)
124+ // libwayland may leave the node linked while notifying; unlink it
125+ // (harmless if it already is) before the slot can be reused.
126+ purego.Syscall6(symListRemove, l, 0, 0, 0, 0, 0)
127+ listeners.put(l)
128+ if fn != nil {
129+ fn()
130+ }
131+}
A server/client_test.go +359 -0
@@ -0,0 +1,359 @@
1+package server
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+ "time"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// clientDisplay runs a real display and returns it with a stop function that
13+// waits for Run to return (and so for display destruction).
14+func clientDisplay(t *testing.T) (*Display, func()) {
15+ t.Helper()
16+ d, err := NewDisplay()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ ctx, cancel := context.WithCancel(context.Background())
21+ done := make(chan error, 1)
22+ go func() { done <- d.Run(ctx) }()
23+ var once bool
24+ stop := func() {
25+ if once {
26+ return
27+ }
28+ once = true
29+ cancel()
30+ select {
31+ case err := <-done:
32+ if err != nil {
33+ t.Error(err)
34+ }
35+ case <-time.After(3 * time.Second):
36+ t.Fatal("Run did not return")
37+ }
38+ }
39+ t.Cleanup(stop)
40+ return d, stop
41+}
42+
43+func socketPair(t *testing.T) [2]int {
44+ t.Helper()
45+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ t.Cleanup(func() { _ = unix.Close(fds[1]) })
50+ return [2]int{fds[0], fds[1]}
51+}
52+
53+func fdOpen(fd int) bool {
54+ _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
55+ return err == nil
56+}
57+
58+func adopt(t *testing.T, d *Display, fd int) Client {
59+ t.Helper()
60+ var c Client
61+ var err error
62+ if !d.Do(func() { c, err = d.CreateClient(fd) }) {
63+ t.Fatal("display stopped")
64+ }
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ return c
69+}
70+
71+func TestClientFD(t *testing.T) {
72+ d, _ := clientDisplay(t)
73+ fds := socketPair(t)
74+ c := adopt(t, d, fds[0])
75+ var (
76+ got, zero int
77+ err, zerr error
78+ allocs float64
79+ )
80+ if !d.Do(func() {
81+ got, err = c.FD()
82+ zero, zerr = Client{}.FD()
83+ allocs = testing.AllocsPerRun(100, func() { _, _ = c.FD() })
84+ }) {
85+ t.Fatal("display stopped")
86+ }
87+ if err != nil || got != fds[0] {
88+ t.Fatalf("FD = %d, %v; want %d", got, err, fds[0])
89+ }
90+ if zerr == nil || zero != -1 {
91+ t.Fatalf("invalid client FD = %d, %v", zero, zerr)
92+ }
93+ if allocs != 0 {
94+ t.Fatalf("FD allocs = %g, want 0", allocs)
95+ }
96+}
97+
98+func TestCreateClientFailureKeepsFD(t *testing.T) {
99+ d, _ := clientDisplay(t)
100+ var p [2]int
101+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
102+ t.Fatal(err)
103+ }
104+ defer unix.Close(p[0])
105+ defer unix.Close(p[1])
106+ var err, errNeg error
107+ var c, cn Client
108+ if !d.Do(func() {
109+ c, err = d.CreateClient(p[0]) // not a socket: SO_PEERCRED fails
110+ cn, errNeg = d.CreateClient(-1)
111+ }) {
112+ t.Fatal("display stopped")
113+ }
114+ if err == nil || c != (Client{}) || errNeg == nil || cn != (Client{}) {
115+ t.Fatalf("got %v %v, %v %v; want errors", c, err, cn, errNeg)
116+ }
117+ if !fdOpen(p[0]) {
118+ t.Fatal("failed CreateClient closed the fd")
119+ }
120+}
121+
122+func TestCreateClientOwnsFD(t *testing.T) {
123+ d, _ := clientDisplay(t)
124+ fds := socketPair(t)
125+ c := adopt(t, d, fds[0])
126+ gone := make(chan struct{})
127+ d.Do(func() { c.OnDestroy(func() { close(gone) }) })
128+ if !fdOpen(fds[0]) {
129+ t.Fatal("adopted fd closed early")
130+ }
131+ _ = unix.Close(fds[1])
132+ select {
133+ case <-gone:
134+ case <-time.After(3 * time.Second):
135+ t.Fatal("client not destroyed")
136+ }
137+ // The listener runs before libwayland closes the fd; Do returns only once
138+ // the destruction that ran it has finished.
139+ d.Do(func() {})
140+ if fdOpen(fds[0]) {
141+ t.Fatal("libwayland did not close the adopted fd")
142+ }
143+}
144+
145+// A raw client speaks the wire protocol over the other socketpair end.
146+func wireMsg(id uint32, opcode uint16, args ...[]byte) []byte {
147+ var body []byte
148+ for _, a := range args {
149+ body = append(body, a...)
150+ }
151+ b := make([]byte, 8, 8+len(body))
152+ binary.LittleEndian.PutUint32(b, id)
153+ binary.LittleEndian.PutUint32(b[4:], uint32(8+len(body))<<16|uint32(opcode))
154+ return append(b, body...)
155+}
156+
157+func u32(v uint32) []byte { return binary.LittleEndian.AppendUint32(nil, v) }
158+
159+func wireString(s string) []byte {
160+ b := u32(uint32(len(s) + 1))
161+ b = append(b, s...)
162+ b = append(b, 0)
163+ for len(b)%4 != 0 {
164+ b = append(b, 0)
165+ }
166+ return b
167+}
168+
169+func TestCreateClientBindsRegistry(t *testing.T) {
170+ iface := &Interface{Name: "adopted_client_test", Version: 1}
171+ if err := NewInterfaces(iface); err != nil {
172+ t.Fatal(err)
173+ }
174+ d, err := NewDisplay()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ bound := make(chan Client, 1)
179+ if err := d.CreateGlobal(iface, 1, func(c Client, version, id uint32) { bound <- c }); err != nil {
180+ t.Fatal(err)
181+ }
182+ ctx, cancel := context.WithCancel(context.Background())
183+ done := make(chan error, 1)
184+ go func() { done <- d.Run(ctx) }()
185+ defer func() { cancel(); <-done }()
186+
187+ fds := socketPair(t)
188+ c := adopt(t, d, fds[0])
189+ if err := unix.SetsockoptTimeval(fds[1], unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
190+ t.Fatal(err)
191+ }
192+ // wl_display.get_registry(new_id 2), then wl_display.sync(new_id 3).
193+ req := append(wireMsg(1, 1, u32(2)), wireMsg(1, 0, u32(3))...)
194+ if _, err := unix.Write(fds[1], req); err != nil {
195+ t.Fatal(err)
196+ }
197+ // Read until the sync callback's done event (object 3), collecting globals.
198+ var stream []byte
199+ var name uint32
200+ buf := make([]byte, 4096)
201+ for sawDone := false; !sawDone; {
202+ n, err := unix.Read(fds[1], buf)
203+ if err != nil || n == 0 {
204+ t.Fatalf("read: %d, %v", n, err)
205+ }
206+ stream = append(stream, buf[:n]...)
207+ for len(stream) >= 8 {
208+ size := int(binary.LittleEndian.Uint32(stream[4:]) >> 16)
209+ if size < 8 || len(stream) < size {
210+ break
211+ }
212+ id, opcode := binary.LittleEndian.Uint32(stream), uint16(binary.LittleEndian.Uint32(stream[4:]))
213+ body := stream[8:size]
214+ if id == 2 && opcode == 0 { // wl_registry.global(name, interface, version)
215+ strLen := int(binary.LittleEndian.Uint32(body[4:]))
216+ if string(body[8:8+strLen-1]) == iface.Name {
217+ name = binary.LittleEndian.Uint32(body)
218+ }
219+ }
220+ if id == 3 {
221+ sawDone = true
222+ }
223+ stream = stream[size:]
224+ }
225+ }
226+ if name == 0 {
227+ t.Fatal("global not advertised to the adopted client")
228+ }
229+ // wl_registry.bind(name, interface, version, new_id 4)
230+ if _, err := unix.Write(fds[1], wireMsg(2, 0, u32(name), wireString(iface.Name), u32(1), u32(4))); err != nil {
231+ t.Fatal(err)
232+ }
233+ select {
234+ case got := <-bound:
235+ if got != c {
236+ t.Fatalf("bind client %v, want adopted %v", got, c)
237+ }
238+ case <-time.After(3 * time.Second):
239+ t.Fatal("bind never reached the global")
240+ }
241+}
242+
243+func TestOnDestroyOnDisconnect(t *testing.T) {
244+ iface := &Interface{Name: "on_destroy_test", Version: 1}
245+ if err := NewInterfaces(iface); err != nil {
246+ t.Fatal(err)
247+ }
248+ d, _ := clientDisplay(t)
249+ fds := socketPair(t)
250+ c := adopt(t, d, fds[0])
251+ var (
252+ order []int
253+ resAlive, fdOK bool
254+ other = map[Client]int{c: 1}
255+ freeBefore, free int
256+ fired = make(chan struct{})
257+ res *Resource
258+ createErr, fdError error
259+ )
260+ if !d.Do(func() {
261+ res, createErr = c.CreateResource(iface, 1, 2, nil)
262+ for i := 1; i <= 3; i++ {
263+ c.OnDestroy(func() {
264+ order = append(order, i)
265+ if i == 3 {
266+ var fd int
267+ fd, fdError = c.FD()
268+ fdOK = fd == fds[0]
269+ resAlive = res.Alive()
270+ delete(other, c)
271+ close(fired)
272+ }
273+ })
274+ }
275+ freeBefore = len(listeners.free) // after the slots are taken
276+ c.OnDestroy(nil)
277+ Client{}.OnDestroy(func() { t.Error("zero client listener ran") })
278+ }) {
279+ t.Fatal("display stopped")
280+ }
281+ if createErr != nil {
282+ t.Fatal(createErr)
283+ }
284+ select {
285+ case <-fired:
286+ t.Fatal("fired before disconnect")
287+ case <-time.After(50 * time.Millisecond):
288+ }
289+ _ = unix.Close(fds[1])
290+ select {
291+ case <-fired:
292+ case <-time.After(3 * time.Second):
293+ t.Fatal("OnDestroy did not fire on disconnect")
294+ }
295+ d.Do(func() { free = len(listeners.free) })
296+ if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 3 {
297+ t.Fatalf("order %v, want [1 2 3]", order)
298+ }
299+ if fdError != nil || !fdOK || !resAlive {
300+ t.Fatalf("during OnDestroy: fd ok=%v err=%v resource alive=%v", fdOK, fdError, resAlive)
301+ }
302+ if len(other) != 0 || res.Alive() {
303+ t.Fatalf("map %v, resource alive %v", other, res.Alive())
304+ }
305+ if free != freeBefore+3 || len(live.clientGone) != 0 {
306+ t.Fatalf("listener slots not recycled: free %d -> %d, live %d", freeBefore, free, len(live.clientGone))
307+ }
308+}
309+
310+func TestOnDestroyOnDisplayClose(t *testing.T) {
311+ d, err := NewDisplay()
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ fds := socketPair(t)
316+ c, err := d.CreateClient(fds[0])
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ n := 0
321+ c.OnDestroy(func() { n++ })
322+ c.OnDestroy(func() { n += 10 })
323+ d.Close()
324+ d.Close()
325+ if n != 11 {
326+ t.Fatalf("listeners ran %d, want 11 (each once)", n)
327+ }
328+}
329+
330+func TestOnDestroyOnRunStop(t *testing.T) {
331+ d, stop := clientDisplay(t)
332+ fds := socketPair(t)
333+ c := adopt(t, d, fds[0])
334+ n := 0
335+ d.Do(func() { c.OnDestroy(func() { n++ }) })
336+ stop()
337+ if n != 1 {
338+ t.Fatalf("listener ran %d times, want 1", n)
339+ }
340+}
341+
342+// A client destroyed at disconnect must not fire again when the display stops.
343+func TestOnDestroyOnce(t *testing.T) {
344+ d, stop := clientDisplay(t)
345+ fds := socketPair(t)
346+ c := adopt(t, d, fds[0])
347+ fired := make(chan struct{}, 4)
348+ d.Do(func() { c.OnDestroy(func() { fired <- struct{}{} }) })
349+ _ = unix.Close(fds[1])
350+ select {
351+ case <-fired:
352+ case <-time.After(3 * time.Second):
353+ t.Fatal("OnDestroy did not fire")
354+ }
355+ stop()
356+ if len(fired) != 0 {
357+ t.Fatal("OnDestroy fired twice")
358+ }
359+}
@@ -0,0 +1,359 @@
1+package server
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+ "time"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// clientDisplay runs a real display and returns it with a stop function that
13+// waits for Run to return (and so for display destruction).
14+func clientDisplay(t *testing.T) (*Display, func()) {
15+ t.Helper()
16+ d, err := NewDisplay()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ ctx, cancel := context.WithCancel(context.Background())
21+ done := make(chan error, 1)
22+ go func() { done <- d.Run(ctx) }()
23+ var once bool
24+ stop := func() {
25+ if once {
26+ return
27+ }
28+ once = true
29+ cancel()
30+ select {
31+ case err := <-done:
32+ if err != nil {
33+ t.Error(err)
34+ }
35+ case <-time.After(3 * time.Second):
36+ t.Fatal("Run did not return")
37+ }
38+ }
39+ t.Cleanup(stop)
40+ return d, stop
41+}
42+
43+func socketPair(t *testing.T) [2]int {
44+ t.Helper()
45+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ t.Cleanup(func() { _ = unix.Close(fds[1]) })
50+ return [2]int{fds[0], fds[1]}
51+}
52+
53+func fdOpen(fd int) bool {
54+ _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
55+ return err == nil
56+}
57+
58+func adopt(t *testing.T, d *Display, fd int) Client {
59+ t.Helper()
60+ var c Client
61+ var err error
62+ if !d.Do(func() { c, err = d.CreateClient(fd) }) {
63+ t.Fatal("display stopped")
64+ }
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ return c
69+}
70+
71+func TestClientFD(t *testing.T) {
72+ d, _ := clientDisplay(t)
73+ fds := socketPair(t)
74+ c := adopt(t, d, fds[0])
75+ var (
76+ got, zero int
77+ err, zerr error
78+ allocs float64
79+ )
80+ if !d.Do(func() {
81+ got, err = c.FD()
82+ zero, zerr = Client{}.FD()
83+ allocs = testing.AllocsPerRun(100, func() { _, _ = c.FD() })
84+ }) {
85+ t.Fatal("display stopped")
86+ }
87+ if err != nil || got != fds[0] {
88+ t.Fatalf("FD = %d, %v; want %d", got, err, fds[0])
89+ }
90+ if zerr == nil || zero != -1 {
91+ t.Fatalf("invalid client FD = %d, %v", zero, zerr)
92+ }
93+ if allocs != 0 {
94+ t.Fatalf("FD allocs = %g, want 0", allocs)
95+ }
96+}
97+
98+func TestCreateClientFailureKeepsFD(t *testing.T) {
99+ d, _ := clientDisplay(t)
100+ var p [2]int
101+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
102+ t.Fatal(err)
103+ }
104+ defer unix.Close(p[0])
105+ defer unix.Close(p[1])
106+ var err, errNeg error
107+ var c, cn Client
108+ if !d.Do(func() {
109+ c, err = d.CreateClient(p[0]) // not a socket: SO_PEERCRED fails
110+ cn, errNeg = d.CreateClient(-1)
111+ }) {
112+ t.Fatal("display stopped")
113+ }
114+ if err == nil || c != (Client{}) || errNeg == nil || cn != (Client{}) {
115+ t.Fatalf("got %v %v, %v %v; want errors", c, err, cn, errNeg)
116+ }
117+ if !fdOpen(p[0]) {
118+ t.Fatal("failed CreateClient closed the fd")
119+ }
120+}
121+
122+func TestCreateClientOwnsFD(t *testing.T) {
123+ d, _ := clientDisplay(t)
124+ fds := socketPair(t)
125+ c := adopt(t, d, fds[0])
126+ gone := make(chan struct{})
127+ d.Do(func() { c.OnDestroy(func() { close(gone) }) })
128+ if !fdOpen(fds[0]) {
129+ t.Fatal("adopted fd closed early")
130+ }
131+ _ = unix.Close(fds[1])
132+ select {
133+ case <-gone:
134+ case <-time.After(3 * time.Second):
135+ t.Fatal("client not destroyed")
136+ }
137+ // The listener runs before libwayland closes the fd; Do returns only once
138+ // the destruction that ran it has finished.
139+ d.Do(func() {})
140+ if fdOpen(fds[0]) {
141+ t.Fatal("libwayland did not close the adopted fd")
142+ }
143+}
144+
145+// A raw client speaks the wire protocol over the other socketpair end.
146+func wireMsg(id uint32, opcode uint16, args ...[]byte) []byte {
147+ var body []byte
148+ for _, a := range args {
149+ body = append(body, a...)
150+ }
151+ b := make([]byte, 8, 8+len(body))
152+ binary.LittleEndian.PutUint32(b, id)
153+ binary.LittleEndian.PutUint32(b[4:], uint32(8+len(body))<<16|uint32(opcode))
154+ return append(b, body...)
155+}
156+
157+func u32(v uint32) []byte { return binary.LittleEndian.AppendUint32(nil, v) }
158+
159+func wireString(s string) []byte {
160+ b := u32(uint32(len(s) + 1))
161+ b = append(b, s...)
162+ b = append(b, 0)
163+ for len(b)%4 != 0 {
164+ b = append(b, 0)
165+ }
166+ return b
167+}
168+
169+func TestCreateClientBindsRegistry(t *testing.T) {
170+ iface := &Interface{Name: "adopted_client_test", Version: 1}
171+ if err := NewInterfaces(iface); err != nil {
172+ t.Fatal(err)
173+ }
174+ d, err := NewDisplay()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ bound := make(chan Client, 1)
179+ if err := d.CreateGlobal(iface, 1, func(c Client, version, id uint32) { bound <- c }); err != nil {
180+ t.Fatal(err)
181+ }
182+ ctx, cancel := context.WithCancel(context.Background())
183+ done := make(chan error, 1)
184+ go func() { done <- d.Run(ctx) }()
185+ defer func() { cancel(); <-done }()
186+
187+ fds := socketPair(t)
188+ c := adopt(t, d, fds[0])
189+ if err := unix.SetsockoptTimeval(fds[1], unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
190+ t.Fatal(err)
191+ }
192+ // wl_display.get_registry(new_id 2), then wl_display.sync(new_id 3).
193+ req := append(wireMsg(1, 1, u32(2)), wireMsg(1, 0, u32(3))...)
194+ if _, err := unix.Write(fds[1], req); err != nil {
195+ t.Fatal(err)
196+ }
197+ // Read until the sync callback's done event (object 3), collecting globals.
198+ var stream []byte
199+ var name uint32
200+ buf := make([]byte, 4096)
201+ for sawDone := false; !sawDone; {
202+ n, err := unix.Read(fds[1], buf)
203+ if err != nil || n == 0 {
204+ t.Fatalf("read: %d, %v", n, err)
205+ }
206+ stream = append(stream, buf[:n]...)
207+ for len(stream) >= 8 {
208+ size := int(binary.LittleEndian.Uint32(stream[4:]) >> 16)
209+ if size < 8 || len(stream) < size {
210+ break
211+ }
212+ id, opcode := binary.LittleEndian.Uint32(stream), uint16(binary.LittleEndian.Uint32(stream[4:]))
213+ body := stream[8:size]
214+ if id == 2 && opcode == 0 { // wl_registry.global(name, interface, version)
215+ strLen := int(binary.LittleEndian.Uint32(body[4:]))
216+ if string(body[8:8+strLen-1]) == iface.Name {
217+ name = binary.LittleEndian.Uint32(body)
218+ }
219+ }
220+ if id == 3 {
221+ sawDone = true
222+ }
223+ stream = stream[size:]
224+ }
225+ }
226+ if name == 0 {
227+ t.Fatal("global not advertised to the adopted client")
228+ }
229+ // wl_registry.bind(name, interface, version, new_id 4)
230+ if _, err := unix.Write(fds[1], wireMsg(2, 0, u32(name), wireString(iface.Name), u32(1), u32(4))); err != nil {
231+ t.Fatal(err)
232+ }
233+ select {
234+ case got := <-bound:
235+ if got != c {
236+ t.Fatalf("bind client %v, want adopted %v", got, c)
237+ }
238+ case <-time.After(3 * time.Second):
239+ t.Fatal("bind never reached the global")
240+ }
241+}
242+
243+func TestOnDestroyOnDisconnect(t *testing.T) {
244+ iface := &Interface{Name: "on_destroy_test", Version: 1}
245+ if err := NewInterfaces(iface); err != nil {
246+ t.Fatal(err)
247+ }
248+ d, _ := clientDisplay(t)
249+ fds := socketPair(t)
250+ c := adopt(t, d, fds[0])
251+ var (
252+ order []int
253+ resAlive, fdOK bool
254+ other = map[Client]int{c: 1}
255+ freeBefore, free int
256+ fired = make(chan struct{})
257+ res *Resource
258+ createErr, fdError error
259+ )
260+ if !d.Do(func() {
261+ res, createErr = c.CreateResource(iface, 1, 2, nil)
262+ for i := 1; i <= 3; i++ {
263+ c.OnDestroy(func() {
264+ order = append(order, i)
265+ if i == 3 {
266+ var fd int
267+ fd, fdError = c.FD()
268+ fdOK = fd == fds[0]
269+ resAlive = res.Alive()
270+ delete(other, c)
271+ close(fired)
272+ }
273+ })
274+ }
275+ freeBefore = len(listeners.free) // after the slots are taken
276+ c.OnDestroy(nil)
277+ Client{}.OnDestroy(func() { t.Error("zero client listener ran") })
278+ }) {
279+ t.Fatal("display stopped")
280+ }
281+ if createErr != nil {
282+ t.Fatal(createErr)
283+ }
284+ select {
285+ case <-fired:
286+ t.Fatal("fired before disconnect")
287+ case <-time.After(50 * time.Millisecond):
288+ }
289+ _ = unix.Close(fds[1])
290+ select {
291+ case <-fired:
292+ case <-time.After(3 * time.Second):
293+ t.Fatal("OnDestroy did not fire on disconnect")
294+ }
295+ d.Do(func() { free = len(listeners.free) })
296+ if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 3 {
297+ t.Fatalf("order %v, want [1 2 3]", order)
298+ }
299+ if fdError != nil || !fdOK || !resAlive {
300+ t.Fatalf("during OnDestroy: fd ok=%v err=%v resource alive=%v", fdOK, fdError, resAlive)
301+ }
302+ if len(other) != 0 || res.Alive() {
303+ t.Fatalf("map %v, resource alive %v", other, res.Alive())
304+ }
305+ if free != freeBefore+3 || len(live.clientGone) != 0 {
306+ t.Fatalf("listener slots not recycled: free %d -> %d, live %d", freeBefore, free, len(live.clientGone))
307+ }
308+}
309+
310+func TestOnDestroyOnDisplayClose(t *testing.T) {
311+ d, err := NewDisplay()
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ fds := socketPair(t)
316+ c, err := d.CreateClient(fds[0])
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ n := 0
321+ c.OnDestroy(func() { n++ })
322+ c.OnDestroy(func() { n += 10 })
323+ d.Close()
324+ d.Close()
325+ if n != 11 {
326+ t.Fatalf("listeners ran %d, want 11 (each once)", n)
327+ }
328+}
329+
330+func TestOnDestroyOnRunStop(t *testing.T) {
331+ d, stop := clientDisplay(t)
332+ fds := socketPair(t)
333+ c := adopt(t, d, fds[0])
334+ n := 0
335+ d.Do(func() { c.OnDestroy(func() { n++ }) })
336+ stop()
337+ if n != 1 {
338+ t.Fatalf("listener ran %d times, want 1", n)
339+ }
340+}
341+
342+// A client destroyed at disconnect must not fire again when the display stops.
343+func TestOnDestroyOnce(t *testing.T) {
344+ d, stop := clientDisplay(t)
345+ fds := socketPair(t)
346+ c := adopt(t, d, fds[0])
347+ fired := make(chan struct{}, 4)
348+ d.Do(func() { c.OnDestroy(func() { fired <- struct{}{} }) })
349+ _ = unix.Close(fds[1])
350+ select {
351+ case <-fired:
352+ case <-time.After(3 * time.Second):
353+ t.Fatal("OnDestroy did not fire")
354+ }
355+ stop()
356+ if len(fired) != 0 {
357+ t.Fatal("OnDestroy fired twice")
358+ }
359+}
M server/display.go +5 -2
@@ -20,7 +20,8 @@ type Handler func(r *Resource, opcode uint32, args []Arg)
2020 // BindFunc runs when a client binds a global.
2121 type BindFunc func(c Client, version, id uint32)
2222
23-// Client is a connected wl_client.
23+// Client is a connected wl_client. It is a comparable handle to the C object,
24+// usable as a map key until the client is destroyed (see Client.OnDestroy).
2425 type Client struct{ c uintptr }
2526
2627 // Resource is a server-side wl_resource.
@@ -45,7 +46,9 @@ var live = struct {
4546 resources map[uintptr]*Resource
4647 binds map[uintptr]BindFunc
4748 nextBind uintptr
48-}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}}
49+ // clientGone maps wl_listener addresses to OnDestroy functions.
50+ clientGone map[uintptr]func()
51+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}}
4952
5053 // Display owns a wl_display. All methods except Do must run on the goroutine
5154 // that calls Run, or before Run starts.
@@ -20,7 +20,8 @@ type Handler func(r *Resource, opcode uint32, args []Arg)
20 // BindFunc runs when a client binds a global.20 // BindFunc runs when a client binds a global.
21 type BindFunc func(c Client, version, id uint32)21 type BindFunc func(c Client, version, id uint32)
22 22
23-// Client is a connected wl_client.23+// Client is a connected wl_client. It is a comparable handle to the C object,
24+// usable as a map key until the client is destroyed (see Client.OnDestroy).
24 type Client struct{ c uintptr }25 type Client struct{ c uintptr }
25 26
26 // Resource is a server-side wl_resource.27 // Resource is a server-side wl_resource.
@@ -45,7 +46,9 @@ var live = struct {
45 resources map[uintptr]*Resource46 resources map[uintptr]*Resource
46 binds map[uintptr]BindFunc47 binds map[uintptr]BindFunc
47 nextBind uintptr48 nextBind uintptr
48-}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}}49+ // clientGone maps wl_listener addresses to OnDestroy functions.
50+ clientGone map[uintptr]func()
51+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}}
49 52
50 // Display owns a wl_display. All methods except Do must run on the goroutine53 // Display owns a wl_display. All methods except Do must run on the goroutine
51 // that calls Run, or before Run starts.54 // that calls Run, or before Run starts.
M server/lib.go +11 -1
@@ -30,7 +30,8 @@ var (
3030
3131 symDisplayFlushClients, symEventLoopDispatch, symResourceCreate uintptr
3232 symResourceSetDispatcher, symResourcePostEventArr, symResourceDestroy, symResourceGetID uintptr
33- symClientGetCredentials uintptr
33+ symClientGetCredentials, symClientGetFD, symClientCreate uintptr
34+ symClientAddDestroyListener, symListRemove uintptr
3435 symErrnoLocation uintptr
3536 // wl_fixes support (libwayland 1.26+); zero on older libraries.
3637 symFixesAckGlobalRemove uintptr
@@ -39,6 +40,7 @@ var (
3940 // of each for the whole process.
4041 cbDispatcher uintptr
4142 cbDestroy uintptr
43+ cbClientGone uintptr
4244 cbBind uintptr
4345 cbWake uintptr
4446 )
@@ -76,6 +78,10 @@ func load() error {
7678 {"wl_resource_create", &symResourceCreate},
7779 {"wl_resource_get_id", &symResourceGetID},
7880 {"wl_client_get_credentials", &symClientGetCredentials},
81+ {"wl_client_get_fd", &symClientGetFD},
82+ {"wl_client_create", &symClientCreate},
83+ {"wl_client_add_destroy_listener", &symClientAddDestroyListener},
84+ {"wl_list_remove", &symListRemove},
7985 {"wl_resource_set_dispatcher", &symResourceSetDispatcher},
8086 {"wl_resource_post_event_array", &symResourcePostEventArr},
8187 {"wl_resource_destroy", &symResourceDestroy},
@@ -110,6 +116,10 @@ func load() error {
110116 destroyed(a.Int(0))
111117 return 0
112118 })
119+ cbClientGone = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
120+ clientGone(a.Int(0))
121+ return 0
122+ })
113123 cbBind = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
114124 bind(a.Int(0), a.Int(1), uint32(a.Int(2)), uint32(a.Int(3)))
115125 return 0
@@ -30,7 +30,8 @@ var (
30 30
31 symDisplayFlushClients, symEventLoopDispatch, symResourceCreate uintptr31 symDisplayFlushClients, symEventLoopDispatch, symResourceCreate uintptr
32 symResourceSetDispatcher, symResourcePostEventArr, symResourceDestroy, symResourceGetID uintptr32 symResourceSetDispatcher, symResourcePostEventArr, symResourceDestroy, symResourceGetID uintptr
33- symClientGetCredentials uintptr33+ symClientGetCredentials, symClientGetFD, symClientCreate uintptr
34+ symClientAddDestroyListener, symListRemove uintptr
34 symErrnoLocation uintptr35 symErrnoLocation uintptr
35 // wl_fixes support (libwayland 1.26+); zero on older libraries.36 // wl_fixes support (libwayland 1.26+); zero on older libraries.
36 symFixesAckGlobalRemove uintptr37 symFixesAckGlobalRemove uintptr
@@ -39,6 +40,7 @@ var (
39 // of each for the whole process.40 // of each for the whole process.
40 cbDispatcher uintptr41 cbDispatcher uintptr
41 cbDestroy uintptr42 cbDestroy uintptr
43+ cbClientGone uintptr
42 cbBind uintptr44 cbBind uintptr
43 cbWake uintptr45 cbWake uintptr
44 )46 )
@@ -76,6 +78,10 @@ func load() error {
76 {"wl_resource_create", &symResourceCreate},78 {"wl_resource_create", &symResourceCreate},
77 {"wl_resource_get_id", &symResourceGetID},79 {"wl_resource_get_id", &symResourceGetID},
78 {"wl_client_get_credentials", &symClientGetCredentials},80 {"wl_client_get_credentials", &symClientGetCredentials},
81+ {"wl_client_get_fd", &symClientGetFD},
82+ {"wl_client_create", &symClientCreate},
83+ {"wl_client_add_destroy_listener", &symClientAddDestroyListener},
84+ {"wl_list_remove", &symListRemove},
79 {"wl_resource_set_dispatcher", &symResourceSetDispatcher},85 {"wl_resource_set_dispatcher", &symResourceSetDispatcher},
80 {"wl_resource_post_event_array", &symResourcePostEventArr},86 {"wl_resource_post_event_array", &symResourcePostEventArr},
81 {"wl_resource_destroy", &symResourceDestroy},87 {"wl_resource_destroy", &symResourceDestroy},
@@ -110,6 +116,10 @@ func load() error {
110 destroyed(a.Int(0))116 destroyed(a.Int(0))
111 return 0117 return 0
112 })118 })
119+ cbClientGone = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
120+ clientGone(a.Int(0))
121+ return 0
122+ })
113 cbBind = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {123 cbBind = purego.NewCallbackInts(func(a *purego.CallbackArgs) uintptr {
114 bind(a.Int(0), a.Int(1), uint32(a.Int(2)), uint32(a.Int(3)))124 bind(a.Int(0), a.Int(1), uint32(a.Int(2)), uint32(a.Int(3)))
115 return 0125 return 0

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