v0.11.0 f5b8db4

display.go RawHistoryFolder

1package server
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"runtime"
9	"strings"
10	"sync"
11	"sync/atomic"
12	"unsafe"
13
14	"github.com/bnema/purego"
15	"golang.org/x/sys/unix"
16)
17
18// Handler receives every request sent to a resource. args borrows libwayland's
19// argument array and must not be retained after the handler returns.
20type Handler func(r *Resource, opcode uint32, args []Arg)
21
22// BindFunc runs when a client binds a global.
23type BindFunc func(c Client, version, id uint32)
24
25// Client is a connected wl_client. It is a comparable handle to the C object,
26// usable as a map key until the client is destroyed (see Client.OnDestroy).
27type Client struct{ c uintptr }
28
29// Resource is a server-side wl_resource.
30type Resource struct {
31	c         uintptr
32	id        uint32
33	version   int32
34	client    uintptr
35	iface     *Interface
36	handler   Handler
37	OnDestroy func()
38	gone      bool
39	bare      bool // created by libwayland, no destroy callback
40
41	// Data holds the owner's state, typically the generated wrapper.
42	Data any
43}
44
45// live holds Go state reachable from C callbacks. It is only touched on the
46// display goroutine, so it needs no lock.
47var live = struct {
48	resources map[uintptr]*Resource
49	binds     map[uintptr]BindFunc
50	nextBind  uintptr
51	// clientGone maps wl_listener addresses to OnDestroy functions.
52	clientGone map[uintptr]func()
53	// fds maps AddFD tokens to their readable callbacks.
54	fds    map[uintptr]func()
55	nextFD uintptr
56}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}, clientGone: map[uintptr]func(){}, fds: map[uintptr]func(){}}
57
58// Display owns a wl_display. All methods except Do and Post must run on the
59// goroutine that calls Run, or before Run starts.
60//
61// Resource, bind and client-listener state is shared by the whole process, so
62// only one Display may be running at a time per process.
63//
64// Do and Post wake the loop through an eventfd so calls run at once instead of
65// after the dispatch timeout.
66type Display struct {
67	c     uintptr
68	loop  uintptr
69	calls chan func()
70	wake  int
71	// wakeSrc owns libwayland's duplicate of wake; only removing the source
72	// closes it, destroying the display does not.
73	wakeSrc uintptr
74	stopped chan struct{}
75	state   atomic.Uint32
76
77	// postMu guards posts and closed. spare is only used by the display
78	// goroutine: it is the emptied batch swapped back in on the next drain, so
79	// steady-state Post does not allocate.
80	postMu sync.Mutex
81	posts  []func()
82	spare  []func()
83	// armed is true while an eventfd wake for posts is pending, so a burst of
84	// Posts writes the eventfd once.
85	armed  atomic.Bool
86	closed atomic.Bool // set under postMu before the wake fd is closed
87	// wakeWrites counts eventfd writes made for posts (diagnostics and tests).
88	wakeWrites atomic.Uint64
89
90	// sources are the AddFD sources not yet removed (display goroutine only).
91	sources map[*Source]struct{}
92}
93
94// maxDrainRounds bounds how many times one loop iteration re-drains calls and
95// posts that were queued by callbacks, so a self-posting callback cannot starve
96// libwayland's dispatch and client flushing.
97const maxDrainRounds = 8
98
99func NewDisplay() (*Display, error) {
100	if err := load(); err != nil {
101		return nil, err
102	}
103	c := wlDisplayCreate()
104	if c == 0 {
105		return nil, errors.New("purego-libwayland: wl_display_create failed")
106	}
107	wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
108	if err != nil {
109		wlDisplayDestroy(c)
110		return nil, fmt.Errorf("purego-libwayland: eventfd: %w", err)
111	}
112	d := &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func(), 64), wake: wake, stopped: make(chan struct{}), sources: map[*Source]struct{}{}}
113	// The callback only drains the counter; Run then executes queued calls.
114	if d.wakeSrc = wlEventLoopAddFD(d.loop, int32(wake), 1 /* WL_EVENT_READABLE */, cbWake, 0); d.wakeSrc == 0 {
115		unix.Close(wake)
116		wlDisplayDestroy(c)
117		return nil, errors.New("purego-libwayland: wl_event_loop_add_fd failed")
118	}
119	return d, nil
120}
121
122// AddSocketFD serves clients on an already bound, listening unix socket.
123func (d *Display) AddSocketFD(fd int) error {
124	if wlDisplayAddSocketFD(d.c, int32(fd)) != 0 {
125		return fmt.Errorf("purego-libwayland: wl_display_add_socket_fd(%d) failed", fd)
126	}
127	return nil
128}
129
130func (d *Display) CreateGlobal(iface *Interface, version int32, bindFn BindFunc) error {
131	_, err := d.AddGlobal(iface, version, bindFn)
132	return err
133}
134
135// Global is a global that can be withdrawn, such as a wl_output on hotplug.
136type Global struct{ c uintptr }
137
138// AddGlobal advertises a global and returns a handle to remove it later.
139func (d *Display) AddGlobal(iface *Interface, version int32, bindFn BindFunc) (*Global, error) {
140	live.nextBind++
141	token := live.nextBind
142	live.binds[token] = bindFn
143	c := wlGlobalCreate(d.c, iface.c, version, token, cbBind)
144	if c == 0 {
145		delete(live.binds, token)
146		return nil, fmt.Errorf("purego-libwayland: wl_global_create(%s) failed", iface.Name)
147	}
148	return &Global{c: c}, nil
149}
150
151// Remove withdraws the global: clients get wl_registry.global_remove.
152// Existing resources stay valid. A client that binds before it sees the
153// removal still gets its bind function called, so it always receives a
154// resource (the protocol allows binding a just-removed global); the bind
155// function should then send inert state. The global is freed with the
156// display. Display goroutine only; idempotent.
157func (g *Global) Remove() {
158	if g == nil || g.c == 0 {
159		return
160	}
161	wlGlobalRemove(g.c)
162	g.c = 0
163}
164
165// AckGlobalRemove implements wl_fixes.ack_global_remove: libwayland checks
166// that registry announced the removed global name, posting
167// wl_fixes.invalid_ack_remove on fixes otherwise. It reports false when
168// libwayland-server is older than 1.26 and has no support.
169// Display goroutine only.
170func AckGlobalRemove(fixes, registry *Resource, name uint32) bool {
171	if symFixesAckGlobalRemove == 0 {
172		return false
173	}
174	if fixes == nil || fixes.gone || registry == nil || registry.gone {
175		return true
176	}
177	wlFixesHandleAckGlobalRemove(fixes.c, registry.c, name)
178	return true
179}
180
181// Close destroys a display that has not started Run. It is a no-op once Run
182// has started; Run owns destruction in that case. Call before Run starts.
183func (d *Display) Close() {
184	if !d.state.CompareAndSwap(0, 1) {
185		return
186	}
187	d.destroy()
188	close(d.stopped)
189}
190
191// destroy frees the display and the wake-up fds.
192func (d *Display) destroy() {
193	// Stop accepting posts before the wake fd is closed (its number could be
194	// reused), and drop what never ran.
195	d.postMu.Lock()
196	d.closed.Store(true)
197	clear(d.posts)
198	d.posts, d.spare = nil, nil
199	d.postMu.Unlock()
200	for s := range d.sources {
201		s.remove()
202	}
203	clear(d.sources)
204	wlDisplayDestroyClients(d.c)
205	wlEventSourceRemove(d.wakeSrc)
206	wlDisplayDestroy(d.c)
207	unix.Close(d.wake)
208}
209
210// Run dispatches libwayland on one OS-locked goroutine until ctx is done.
211func (d *Display) Run(ctx context.Context) error {
212	if !d.state.CompareAndSwap(0, 2) {
213		return errors.New("purego-libwayland: display already started or closed")
214	}
215	runtime.LockOSThread()
216	defer runtime.UnlockOSThread()
217	defer close(d.stopped)
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	}
242	consecutiveFailures := 0
243	for {
244		if ctx.Err() != nil {
245			return nil
246		}
247		// Run every queued post and call. Wakes are coalesced, so the eventfd
248		// is not written once per item; it only makes the wait below
249		// return at once when work arrives while the loop is asleep.
250		d.drain()
251		wlDisplayFlushClients(d.c)
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 {
259			errno := lastErrno()
260			consecutiveFailures++
261			if consecutiveFailures < 16 {
262				continue
263			}
264			return fmt.Errorf("purego-libwayland: wl_event_loop_dispatch failed %d times in a row: %w", consecutiveFailures, errno)
265		}
266		consecutiveFailures = 0
267		wlDisplayFlushClients(d.c)
268	}
269}
270
271// openLoopFile wraps a duplicate of the event loop's epoll fd for the netpoller.
272func (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
288// drain runs queued posts and Do calls until none are left, at most
289// maxDrainRounds rounds. Posts are taken again after each call is received and
290// before it runs, so a Post that happened before a Do always runs before that
291// Do's function. When the cap is hit the loop is woken again so the next
292// dispatch returns at once.
293func (d *Display) drain() {
294	for range maxDrainRounds {
295		ran := d.runPosts()
296	calls:
297		for {
298			select {
299			case fn := <-d.calls:
300				d.runPosts()
301				fn()
302				ran = true
303			default:
304				break calls
305			}
306		}
307		if !ran {
308			return
309		}
310	}
311	d.armed.Store(true)
312	d.wakeLoop()
313}
314
315// runPosts runs the posts queued so far and reports whether there were any.
316func (d *Display) runPosts() bool {
317	// Clear armed before taking the batch: a Post that lands after the swap
318	// sees armed false and writes the eventfd again.
319	d.armed.Store(false)
320	d.postMu.Lock()
321	batch := d.posts
322	d.posts, d.spare = d.spare, nil
323	d.postMu.Unlock()
324	for _, fn := range batch {
325		fn()
326	}
327	clear(batch)
328	d.spare = batch[:0]
329	return len(batch) > 0
330}
331
332// wakeOne is the eventfd increment. It is read-only, so sharing it between
333// goroutines is safe and avoids a per-call buffer (which escapes under -race).
334var wakeOne = [8]byte{1}
335
336// wakeLoop writes the eventfd on behalf of posts. The caller must hold postMu
337// (or be the display goroutine, which destroy cannot overlap) so the fd cannot
338// be closed, and its number reused, underneath the write.
339func (d *Display) wakeLoop() {
340	d.wakeWrites.Add(1)
341	_, _ = unix.Write(d.wake, wakeOne[:])
342}
343
344// wakeGuarded wakes the loop from any goroutine: it takes postMu and skips the
345// write once the display is closed, so a late caller never writes to a reused
346// fd number.
347func (d *Display) wakeGuarded() {
348	d.postMu.Lock()
349	defer d.postMu.Unlock()
350	if d.closed.Load() {
351		return
352	}
353	_, _ = unix.Write(d.wake, wakeOne[:])
354}
355
356// Post queues fn to run on the display goroutine and returns at once. Unlike
357// Do it never waits, and unlike Do it may be called from any goroutine,
358// including the display goroutine itself.
359//
360// Posts run in the order they were made, and a Post made before a Do (by the
361// same goroutine, or otherwise known to happen before it) runs before that Do's
362// function. A Post made by a posted function or a Do function runs in a later
363// drain round of the same loop iteration (at most 8 rounds; past that the loop
364// is woken again and continues after one libwayland dispatch). Wakes are coalesced:
365// any number of Posts while one wake is pending write the eventfd once.
366//
367// fn must not block: it runs on the goroutine that serves every client. After
368// the display has stopped or been closed, Post silently drops fn, and posts
369// that had not run when the display stopped are dropped too. In steady state
370// Post does not allocate.
371//
372// The queue is unbounded: Post applies no backpressure, so a producer that
373// outpaces the display goroutine grows memory without limit. Callers that can
374// produce faster than the loop runs must bound or coalesce their own posts.
375func (d *Display) Post(fn func()) {
376	if fn == nil {
377		return
378	}
379	d.postMu.Lock()
380	defer d.postMu.Unlock()
381	if d.closed.Load() {
382		return
383	}
384	d.posts = append(d.posts, fn)
385	// Written under postMu so destroy cannot close the fd underneath.
386	if d.armed.CompareAndSwap(false, true) {
387		d.wakeLoop()
388	}
389}
390
391// Source is an fd registered with the display's event loop by AddFD.
392type Source struct {
393	d     *Display
394	c     uintptr
395	token uintptr
396}
397
398// AddFD calls onReadable on the display goroutine whenever fd is readable. The
399// registration is level-triggered: onReadable must consume the readiness (read
400// the eventfd or timerfd, accept, ...) or it is called again at once. It
401// should not block.
402//
403// onReadable is also called when the fd hangs up or errors (EPOLLHUP,
404// EPOLLERR), which level-triggered polling keeps reporting. The callback should
405// read the fd, and on EOF or error call Remove (it may do so from within the
406// callback), or it is called again at once.
407//
408// libwayland duplicates fd; the caller still owns and must close its own
409// descriptor, and should do so after Remove. Call AddFD only on the display
410// goroutine or before Run starts. Sources not removed are released when the
411// display is destroyed.
412func (d *Display) AddFD(fd int, onReadable func()) (*Source, error) {
413	if fd < 0 {
414		return nil, fmt.Errorf("purego-libwayland: AddFD: invalid fd %d", fd)
415	}
416	if onReadable == nil {
417		return nil, errors.New("purego-libwayland: AddFD: nil callback")
418	}
419	if d.closed.Load() {
420		return nil, errors.New("purego-libwayland: AddFD: display closed")
421	}
422	live.nextFD++
423	token := live.nextFD
424	live.fds[token] = onReadable
425	c := wlEventLoopAddFD(d.loop, int32(fd), 1 /* WL_EVENT_READABLE */, cbFD, token)
426	if c == 0 {
427		delete(live.fds, token)
428		return nil, fmt.Errorf("purego-libwayland: wl_event_loop_add_fd(%d) failed", fd)
429	}
430	s := &Source{d: d, c: c, token: token}
431	d.sources[s] = struct{}{}
432	return s, nil
433}
434
435// Remove stops the callbacks and releases libwayland's duplicate of the fd.
436// It may be called from the source's own callback. Display goroutine only;
437// idempotent, and a no-op after the display was destroyed.
438func (s *Source) Remove() {
439	if s == nil || s.c == 0 {
440		return
441	}
442	delete(s.d.sources, s)
443	s.remove()
444}
445
446func (s *Source) remove() {
447	delete(live.fds, s.token)
448	wlEventSourceRemove(s.c)
449	s.c = 0
450}
451
452// Do runs fn on the display goroutine and waits for it. It returns false if
453// the display loop has stopped (fn was not run, or its run was not confirmed).
454// Do must not be called from the display goroutine: it would deadlock.
455func (d *Display) Do(fn func()) bool {
456	done := make(chan struct{})
457	select {
458	case d.calls <- func() { fn(); close(done) }:
459	case <-d.stopped:
460		return false
461	}
462	d.wakeGuarded()
463	select {
464	case <-done:
465		return true
466	case <-d.stopped:
467		// If both are ready, prefer confirmation of a completed call.
468		select {
469		case <-done:
470			return true
471		default:
472			return false
473		}
474	}
475}
476
477// Stopped is closed after Run returns and the display is destroyed.
478func (d *Display) Stopped() <-chan struct{} { return d.stopped }
479
480// LiveResources reports resources not yet destroyed. Display goroutine only.
481func LiveResources() int { return len(live.resources) }
482
483// Credentials are the peer credentials of a client, read from the socket
484// (SO_PEERCRED) when it connected. UID/GID are effective IDs in the server's
485// namespaces; an unmappable ID is the namespace overflow ID. PID may be zero
486// even for a valid client when its PID namespace is not visible to the server.
487type Credentials struct {
488	PID int
489	UID uint32
490	GID uint32
491}
492
493var credScratch [3]int32
494
495var errInvalidClient = errors.New("purego-libwayland: invalid client")
496
497// Credentials returns the peer credentials of the client. It returns an error
498// for an invalid (zero) Client. It does not allocate. Display goroutine only.
499func (c Client) Credentials() (Credentials, error) {
500	if c.c == 0 {
501		return Credentials{}, errInvalidClient
502	}
503	// Package-level scratch (display goroutine only) avoids heap-escaping locals.
504	purego.Syscall6(symClientGetCredentials, c.c, uintptr(unsafe.Pointer(&credScratch[0])), uintptr(unsafe.Pointer(&credScratch[1])), uintptr(unsafe.Pointer(&credScratch[2])), 0, 0)
505	return Credentials{PID: int(credScratch[0]), UID: uint32(credScratch[1]), GID: uint32(credScratch[2])}, nil
506}
507
508// PID is the process ID of the client, from Credentials. It is zero for an
509// invalid client or a peer outside the server's visible PID namespaces.
510// Display goroutine only.
511func (c Client) PID() int {
512	cr, err := c.Credentials()
513	if err != nil {
514		return 0
515	}
516	return cr.PID
517}
518
519func (c Client) CreateResource(iface *Interface, version int32, id uint32, h Handler) (*Resource, error) {
520	rc := wlResourceCreate(c.c, iface.c, version, id)
521	if rc == 0 {
522		return nil, fmt.Errorf("purego-libwayland: wl_resource_create(%s) failed", iface.Name)
523	}
524	// id 0 asks libwayland to allocate a server-side ID.
525	if id == 0 {
526		id = wlResourceGetID(rc)
527	}
528	r := &Resource{c: rc, id: id, version: version, client: c.c, iface: iface, handler: h}
529	live.resources[rc] = r
530	wlResourceSetDispatcher(rc, cbDispatcher, implMarker(), 0, cbDestroy)
531	return r, nil
532}
533
534func (r *Resource) Alive() bool { return !r.gone }
535func (r *Resource) ID() uint32 {
536	if r.gone {
537		return 0
538	}
539	return r.id
540}
541func (r *Resource) Version() int32 {
542	if r.gone {
543		return 0
544	}
545	return r.version
546}
547func (r *Resource) Client() Client {
548	if r.gone {
549		return Client{}
550	}
551	return Client{r.client}
552}
553func (r *Resource) Iface() *Interface { return r.iface }
554
555// Destroy frees the resource. It is a no-op once the resource is gone, so
556// generated destructor requests and handlers may both call it.
557func (r *Resource) Destroy() {
558	if !r.gone {
559		wlResourceDestroy(r.c)
560		// A bare handle gets no destroy callback to mark it gone.
561		r.gone = r.gone || r.bare
562	}
563}
564
565// eventArgs holds the wl_argument array of the event being posted.
566// libwayland copies it before wl_resource_post_event_array returns.
567// Display goroutine only; post_event_array does not call back into Go, so
568// PostEvent is not re-entrant. No protocol event has more than 8 arguments.
569var eventArgs [16]Arg
570
571// PostEvent sends an event. Arguments must match the event signature; build
572// string and array arguments with a Pinner and unpin it after this returns.
573// Calling it after destruction is safe and does nothing.
574func (r *Resource) PostEvent(opcode uint32, args ...Arg) {
575	if r.gone {
576		return
577	}
578	if len(args) > len(eventArgs) {
579		panic("purego-libwayland: too many event arguments")
580	}
581	var p unsafe.Pointer
582	if len(args) > 0 {
583		copy(eventArgs[:], args)
584		p = unsafe.Pointer(&eventArgs[0])
585	}
586	wlResourcePostEventArr(r.c, opcode, p)
587}
588
589// PostError sends a protocol error and disconnects the client.
590// Calling it after destruction is safe and does nothing.
591func (r *Resource) PostError(code uint32, msg string) {
592	if r.gone {
593		return
594	}
595	// wl_resource_post_error takes a printf format; escape it and pass no
596	// variadic arguments (purego clears AL, as SysV varargs require).
597	b := append([]byte(strings.ReplaceAll(msg, "%", "%%")), 0)
598	var pin runtime.Pinner
599	pin.Pin(&b[0])
600	defer pin.Unpin()
601	wlResourcePostError(r.c, code, uintptr(unsafe.Pointer(&b[0])))
602	runtime.KeepAlive(b)
603}
604
605// implMarker is a non-nil implementation pointer. libwayland only passes it
606// back to our dispatcher.
607func implMarker() uintptr {
608	if marker == 0 {
609		tablesMu.Lock()
610		if err := tables.reserve(8); err != nil {
611			panic(err)
612		}
613		_, marker = tables.alloc(8)
614		tablesMu.Unlock()
615	}
616	return marker
617}
618
619var marker uintptr
620
621// --- C callbacks (display goroutine) ---
622
623func dispatch(_ uintptr, target uintptr, opcode uint32, _ unsafe.Pointer, args unsafe.Pointer) int32 {
624	r := live.resources[target]
625	if r == nil || opcode >= uint32(len(r.iface.Requests)) {
626		return 0
627	}
628	m := &r.iface.Requests[opcode]
629	if r.handler == nil {
630		closeRequestFDs(m.fdPositions, args)
631		return 0
632	}
633	var a []Arg
634	if m.argc > 0 {
635		a = unsafe.Slice((*Arg)(args), m.argc)
636	}
637	r.handler(r, opcode, a)
638	return 0
639}
640
641func destroyed(res uintptr) {
642	r := live.resources[res]
643	delete(live.resources, res)
644	if r == nil {
645		return
646	}
647	r.gone = true
648	if r.OnDestroy != nil {
649		r.OnDestroy()
650	}
651}
652
653// wake drains a Display's eventfd (wl_event_loop_fd_func_t).
654func wake(fd int32, mask uint32, data uintptr) int32 {
655	var buf [8]byte
656	_, _ = unix.Read(int(fd), buf[:])
657	return 0
658}
659
660// fdReadable dispatches an AddFD source (wl_event_loop_fd_func_t).
661func fdReadable(_ int32, _ uint32, data uintptr) int32 {
662	if fn := live.fds[data]; fn != nil {
663		fn()
664	}
665	return 0
666}
667
668func bind(client uintptr, data uintptr, version uint32, id uint32) {
669	if fn := live.binds[data]; fn != nil {
670		fn(Client{client}, version, id)
671	}
672}
673
674// closeRequestFDs takes ownership of fd arguments when there is no handler.
675func closeRequestFDs(positions []int, args unsafe.Pointer) {
676	for _, i := range positions {
677		_ = unix.Close((*Arg)(unsafe.Add(args, i*8)).Fd())
678	}
679}
680
681// argCount counts arguments in a libwayland signature ("?oii", "2n", ...).
682func argCount(sig string) int {
683	n := 0
684	for _, ch := range sig {
685		if ch != '?' && (ch < '0' || ch > '9') {
686			n++
687		}
688	}
689	return n
690}

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