v0.10.0 d5d679d

seat.go RawHistoryFolder

1// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2package seat
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"runtime"
9	"slices"
10	"sync"
11	"unsafe"
12
13	"github.com/bnema/neferwl/internal/adapters/fdwait"
14	"github.com/bnema/purego"
15	"github.com/bnema/zerowrap"
16	"golang.org/x/sys/unix"
17)
18
19var (
20	loadOnce sync.Once
21	loadErr  error
22
23	openSeat      func(listener unsafe.Pointer, data uintptr) uintptr
24	closeSeat     func(seat uintptr) int32
25	disableSeat   func(seat uintptr) int32
26	openDevice    func(seat uintptr, path string, fd *int32) int32
27	closeDevice   func(seat uintptr, id int32) int32
28	seatName      func(seat uintptr) string
29	switchSession func(seat uintptr, session int32) int32
30	getFD         func(seat uintptr) int32
31	dispatch      func(seat uintptr, timeout int32) int32
32
33	listener [2]uintptr
34	// libseat allows one seat per process; callbacks find it here.
35	current *Seat
36)
37
38func load() error {
39	loadOnce.Do(func() {
40		lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
41		if err != nil {
42			loadErr = fmt.Errorf("open libseat.so.1: %w", err)
43			return
44		}
45		defer func() {
46			if r := recover(); r != nil {
47				loadErr = fmt.Errorf("libseat: %v", r)
48			}
49		}()
50		reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
51		reg(&openSeat, "open_seat")
52		reg(&closeSeat, "close_seat")
53		reg(&disableSeat, "disable_seat")
54		reg(&openDevice, "open_device")
55		reg(&closeDevice, "close_device")
56		reg(&seatName, "seat_name")
57		reg(&switchSession, "switch_session")
58		reg(&getFD, "get_fd")
59		reg(&dispatch, "dispatch")
60		listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
61		listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
62	})
63	return loadErr
64}
65
66// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
67// through mu; callbacks only run inside dispatch, which already holds it.
68type Seat struct {
69	mu      sync.Mutex
70	handle  uintptr
71	active  bool
72	devices map[int32]int32 // fd → libseat device id
73	subs    []chan bool
74	log     zerowrap.Logger
75	// beforeDisable runs on a seat disable, before it is acked to the seat
76	// manager: the last moment this process is still DRM master.
77	beforeDisable func()
78	disabler      disabler
79	// waiter is Run's poller: calls that make libseat queue events wake it
80	// to dispatch them.
81	waiter *fdwait.Poller
82}
83
84// disabler acks a seat disable to the seat manager (libseat_disable_seat),
85// which revokes the devices.
86type disabler interface{ disable() }
87
88type libseatDisabler struct{ s *Seat }
89
90func (d libseatDisabler) disable() { disableSeat(d.s.handle) }
91
92// SetBeforeDisable registers f, run on every seat disable before it is
93// acked. f runs inside libseat's dispatch, with the seat locked: it must be
94// short and must not call the Seat. Call it before Run.
95func (s *Seat) SetBeforeDisable(f func()) {
96	s.mu.Lock()
97	defer s.mu.Unlock()
98	s.beforeDisable = f
99}
100
101// Open connects to logind or seatd and waits for the first enable.
102func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
103	if err := load(); err != nil {
104		return nil, err
105	}
106	if current != nil {
107		return nil, errors.New("seat already open")
108	}
109	s := &Seat{devices: make(map[int32]int32), log: log}
110	s.disabler = libseatDisabler{s}
111	current = s
112	s.mu.Lock()
113	defer s.mu.Unlock()
114	s.handle = openSeat(unsafe.Pointer(&listener), 0)
115	runtime.KeepAlive(&listener)
116	if s.handle == 0 {
117		current = nil
118		return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
119	}
120	log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
121	for !s.active {
122		if err := s.dispatchLocked(ctx, 100); err != nil {
123			s.closeLocked()
124			return nil, fmt.Errorf("waiting for seat enable: %w", err)
125		}
126	}
127	return s, nil
128}
129
130func (s *Seat) changed(active bool) {
131	s.active = active
132	s.log.Info().Bool("active", active).Msg("seat")
133	if !active {
134		// Once acked the devices are revoked and the next master inherits
135		// the planes as they are: let the owner of the outputs reset them.
136		if s.beforeDisable != nil {
137			s.beforeDisable()
138		}
139		// Devices are revoked by the seat manager; ack so the VT can switch.
140		s.disabler.disable()
141	}
142	for _, c := range s.subs {
143		select {
144		case c <- active:
145		default:
146			select {
147			case <-c:
148			default:
149			}
150			c <- active
151		}
152	}
153}
154
155// Name returns the seat name, e.g. seat0.
156func (s *Seat) Name() string {
157	s.mu.Lock()
158	defer s.mu.Unlock()
159	return seatName(s.handle)
160}
161
162// Subscribe returns a channel that receives the seat state after every enable/disable.
163// The current state is sent first.
164func (s *Seat) Subscribe() <-chan bool {
165	c := make(chan bool, 1)
166	s.mu.Lock()
167	c <- s.active
168	s.subs = append(s.subs, c)
169	s.mu.Unlock()
170	return c
171}
172
173// Unsubscribe stops sending to a channel from Subscribe.
174func (s *Seat) Unsubscribe(c <-chan bool) {
175	s.mu.Lock()
176	defer s.mu.Unlock()
177	s.subs = slices.DeleteFunc(s.subs, func(x chan bool) bool { return x == c })
178}
179
180func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
181	if ctx.Err() != nil {
182		return ctx.Err()
183	}
184	fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
185	if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
186		return err
187	}
188	if dispatch(s.handle, 0) < 0 {
189		return errors.New("libseat_dispatch failed")
190	}
191	return nil
192}
193
194// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
195// once outputs have restored their CRTC state.
196// It sleeps until the seat fd is readable or a seat call wakes it.
197func (s *Seat) Run(ctx context.Context) error {
198	waiter, err := fdwait.New()
199	if err != nil {
200		return fmt.Errorf("seat waiter: %w", err)
201	}
202	defer waiter.Close()
203	waiter.StopOn(ctx)
204	s.mu.Lock()
205	fd := getFD(s.handle)
206	s.waiter = waiter
207	s.mu.Unlock()
208	defer func() {
209		s.mu.Lock()
210		s.waiter = nil
211		s.mu.Unlock()
212	}()
213	if err := waiter.Pin(fd); err != nil {
214		return fmt.Errorf("seat waiter: %w", err)
215	}
216	fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
217	for ctx.Err() == nil {
218		// Dispatch on every return, a wake included: libseat may queue
219		// events while answering a request, without the fd becoming
220		// readable (its backends ping themselves, the wake covers it too).
221		s.mu.Lock()
222		r := dispatch(s.handle, 0)
223		s.mu.Unlock()
224		if r < 0 {
225			return errors.New("libseat_dispatch failed")
226		}
227		if _, err := waiter.Poll(fds, -1); err != nil && !errors.Is(err, unix.EINTR) {
228			return fmt.Errorf("seat poll: %w", err)
229		}
230	}
231	return nil
232}
233
234// wakeLocked has Run dispatch the events a seat request may have queued.
235func (s *Seat) wakeLocked() {
236	if s.waiter != nil {
237		s.waiter.Wake()
238	}
239}
240
241// OpenDevice opens path through the seat manager and returns its fd.
242func (s *Seat) OpenDevice(path string) (int, error) {
243	s.mu.Lock()
244	defer s.mu.Unlock()
245	var fd int32 = -1
246	id := openDevice(s.handle, path, &fd)
247	s.wakeLocked()
248	if id < 0 {
249		s.log.Warn().Str("path", path).Msg("open device failed")
250		return -1, fmt.Errorf("libseat_open_device %s failed", path)
251	}
252	s.devices[fd] = id
253	s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
254	return int(fd), nil
255}
256
257// CloseDevice releases a fd returned by OpenDevice.
258func (s *Seat) CloseDevice(fd int) {
259	s.mu.Lock()
260	defer s.mu.Unlock()
261	id, ok := s.devices[int32(fd)]
262	if !ok {
263		return
264	}
265	delete(s.devices, int32(fd))
266	closeDevice(s.handle, id)
267	s.wakeLocked()
268	_ = unix.Close(fd)
269	s.log.Info().Int("fd", fd).Msg("device closed")
270}
271
272// SwitchVT asks the seat manager to switch to virtual terminal vt.
273func (s *Seat) SwitchVT(vt int) {
274	s.mu.Lock()
275	defer s.mu.Unlock()
276	s.log.Info().Int("vt", vt).Msg("switch vt")
277	if switchSession(s.handle, int32(vt)) < 0 {
278		s.log.Warn().Int("vt", vt).Msg("switch vt failed")
279	}
280	s.wakeLocked()
281}
282
283// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
284func (s *Seat) Close() {
285	s.mu.Lock()
286	defer s.mu.Unlock()
287	s.closeLocked()
288}
289
290func (s *Seat) closeLocked() {
291	if s.handle == 0 {
292		return
293	}
294	for fd, id := range s.devices {
295		closeDevice(s.handle, id)
296		_ = unix.Close(int(fd))
297	}
298	s.devices = map[int32]int32{}
299	closeSeat(s.handle)
300	s.handle = 0
301	current = nil
302	s.log.Info().Msg("seat closed")
303}

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 23

Tags 10

↑↓ moveenter openesc close