v0.5.0 131f96f

owner.go RawHistoryFolder

1// Package app wires adapters to the pure policy. The Owner goroutine is the
2// only place policy state is read or written; adapters talk to it through
3// channels and the ports.Visual port. Nothing here can unlock a session:
4// unlock exists only as a visual-reported LockReleased lifecycle result.
5package app
6
7import (
8	"context"
9	"errors"
10	"fmt"
11	"os"
12	"slices"
13	"time"
14
15	"github.com/bnema/neferafk/internal/core"
16	"github.com/bnema/neferafk/internal/ports"
17	"github.com/bnema/zerowrap"
18)
19
20const maxQueue = 256
21
22// Inputs are the owner's event channels. Any may be nil (feature absent).
23type Inputs struct {
24	Config   <-chan ports.ConfigChanged
25	Failures <-chan error
26	Wayland  <-chan ports.WaylandEvent
27	System   <-chan ports.SystemEvent
28	Visual   <-chan ports.VisualEvent
29	Control  <-chan ports.ControlRequest
30}
31
32// Outputs are adapter command channels. The owner never blocks on them: sends
33// go through bounded per-channel queues so an adapter that is itself blocked
34// emitting an event cannot deadlock with the owner.
35type Outputs struct {
36	Wayland chan<- ports.WaylandCommand
37	System  chan<- ports.SystemCommand
38}
39
40type Options struct {
41	Config ports.Config
42	Visual ports.Visual
43	// LookupEnv reads the daemon's own startup environment for the PIN env
44	// source (the accepted, documented residual custody). Defaults to os.LookupEnv.
45	LookupEnv func(string) (string, bool)
46	Log       zerowrap.Logger
47	// RetryMin/RetryMax bound the lock (re)spawn backoff; defaults 250ms/5s.
48	RetryMin, RetryMax time.Duration
49	// Gate, when set, makes Run fail with *UnsupportedError unless the latest
50	// Wayland report and the first System report (if ExpectSystem) are
51	// complete within GateTimeout (default 10s). Missing System support fails
52	// at once; missing Wayland globals may still be advertised late.
53	Gate         bool
54	ExpectSystem bool
55	GateTimeout  time.Duration
56}
57
58type Owner struct {
59	policy    *core.Policy
60	cfg       ports.Config
61	startWL   ports.WaylandRequirements
62	startSY   ports.SystemRequirements
63	startAuth ports.AuthConfig
64	opt       Options
65	in        Inputs
66	out       Outputs
67	log       zerowrap.Logger
68	initial   []ports.PolicyEffect
69
70	wq         []ports.WaylandCommand
71	sq         []ports.SystemCommand
72	regs       []ports.IdleNotification
73	regGen     map[uint64]ports.Generation
74	regOrigin  map[uint64]time.Time              // when policy first registered each ID
75	idle       map[uint32]map[time.Duration]bool // adapter proxies currently idle, per seat and duration
76	seats      map[uint32]bool                   // seats from the latest capability report
77	seatsKnown bool
78	fired      map[uint64]bool // registrations already delivered to policy
79	deferred   *time.Timer     // fires registrations whose origin+After lies ahead
80	lockGen    ports.Generation
81	delay      time.Duration
82	retry      *time.Timer
83	sleeping   bool
84	sleepCycle uint64
85	wlCaps     *ports.Capabilities
86	sysCaps    *ports.SystemCapabilities
87	gated      bool
88}
89
90func NewOwner(o Options, in Inputs, out Outputs) (*Owner, error) {
91	if o.Visual == nil {
92		return nil, errors.New("app: visual port required")
93	}
94	if o.LookupEnv == nil {
95		o.LookupEnv = os.LookupEnv
96	}
97	if o.RetryMin <= 0 {
98		o.RetryMin = 250 * time.Millisecond
99	}
100	if o.RetryMax < o.RetryMin {
101		o.RetryMax = max(5*time.Second, o.RetryMin)
102	}
103	if o.GateTimeout <= 0 {
104		o.GateTimeout = 10 * time.Second
105	}
106	p, initial, err := core.New(o.Config, time.Now())
107	if err != nil {
108		return nil, err
109	}
110	w, s := Requirements(o.Config)
111	ow := &Owner{policy: p, cfg: o.Config, startAuth: o.Config.Auth, startWL: w, startSY: s, opt: o, in: in, out: out, log: o.Log.WithField("component", "app"), initial: initial, regGen: map[uint64]ports.Generation{}, regOrigin: map[uint64]time.Time{}, idle: map[uint32]map[time.Duration]bool{}, seats: map[uint32]bool{}, fired: map[uint64]bool{}, delay: o.RetryMin}
112	ow.retry = time.NewTimer(time.Hour)
113	ow.retry.Stop()
114	ow.deferred = time.NewTimer(time.Hour)
115	ow.deferred.Stop()
116	ow.gated = !o.Gate
117	return ow, nil
118}
119
120// Run is the policy owner goroutine. It returns nil on ctx cancellation, an
121// *UnsupportedError when the startup gate fails, or a fatal channel error.
122func (o *Owner) Run(ctx context.Context) error {
123	defer o.retry.Stop()
124	defer o.deferred.Stop()
125	defer o.shutdown()
126	for _, e := range o.initial {
127		if err := o.apply(ctx, e); err != nil {
128			return err
129		}
130	}
131	o.initial = nil
132	var gate <-chan time.Time
133	var gateTimer *time.Timer
134	if o.opt.Gate && !o.gated {
135		gateTimer = time.NewTimer(o.opt.GateTimeout)
136		defer gateTimer.Stop()
137		gate = gateTimer.C
138	}
139	for {
140		var wc chan<- ports.WaylandCommand
141		var wv ports.WaylandCommand
142		if len(o.wq) > 0 {
143			wc, wv = o.out.Wayland, o.wq[0]
144		}
145		var sc chan<- ports.SystemCommand
146		var sv ports.SystemCommand
147		if len(o.sq) > 0 {
148			sc, sv = o.out.System, o.sq[0]
149		}
150		var err error
151		select {
152		case <-ctx.Done():
153			return nil
154		case <-gate:
155			if o.wlCaps != nil && (o.sysCaps != nil || !o.opt.ExpectSystem) {
156				return &UnsupportedError{Missing: unsupported(o.wlCaps, o.sysCaps)}
157			}
158			return &UnsupportedError{Missing: []string{"capability reports not received before timeout"}}
159		case wc <- wv:
160			o.wq = o.wq[1:]
161		case sc <- sv:
162			o.sq = o.sq[1:]
163		case ev, ok := <-o.in.Config:
164			if !ok {
165				return errors.New("config channel closed")
166			}
167			err = o.reload(ctx, ev.Config)
168		case e, ok := <-o.in.Failures:
169			if !ok {
170				o.in.Failures = nil
171				continue
172			}
173			o.log.Warn().Err(e).Msg("config change rejected; keeping last valid configuration")
174		case ev, ok := <-o.in.Wayland:
175			if !ok {
176				return errors.New("wayland channel closed")
177			}
178			err = o.wayland(ctx, ev)
179		case ev, ok := <-o.in.System:
180			if !ok {
181				return errors.New("system channel closed")
182			}
183			err = o.system(ctx, ev)
184		case ev, ok := <-o.in.Visual:
185			if !ok {
186				return errors.New("visual channel closed")
187			}
188			err = o.visual(ctx, ev)
189		case req, ok := <-o.in.Control:
190			if !ok {
191				o.in.Control = nil
192				continue
193			}
194			err = o.control(ctx, req)
195		case <-o.retry.C:
196			err = o.startLock(ctx)
197		case <-o.deferred.C:
198			err = o.fireDue(ctx)
199		}
200		if err != nil {
201			return err
202		}
203		if e := o.checkGate(); e != nil {
204			return e
205		}
206		if o.gated && gateTimer != nil {
207			gateTimer.Stop() // gate passed: the timeout must never fire afterwards
208			gateTimer, gate = nil, nil
209		}
210		if len(o.wq) > maxQueue || len(o.sq) > maxQueue {
211			return errors.New("adapter command queue overflow")
212		}
213	}
214}
215
216func (o *Owner) checkGate() error {
217	if o.gated || o.wlCaps == nil || o.opt.ExpectSystem && o.sysCaps == nil {
218		return nil
219	}
220	if m := unsupported(o.wlCaps, o.sysCaps); len(m) > 0 {
221		if len(o.wlCaps.Missing) > 0 && len(unsupported(nil, o.sysCaps)) == 0 {
222			return nil // wait for a later Wayland report or the gate timeout
223		}
224		o.gated = true
225		return &UnsupportedError{Missing: m}
226	}
227	o.gated = true
228	if w := suspendWarning(o.cfg, o.sysCaps); w != "" {
229		o.log.Warn().Msg(w)
230	}
231	return nil
232}
233
234// shutdown runs on every Run exit. A held (or being acquired) lock is never
235// torn down: the visual is detached and keeps its lock and auth UI.
236func (o *Owner) shutdown() {
237	st := o.policy.State()
238	var err error
239	if st.Acquiring || st.Protected {
240		// Never tear down a lock we hold: leave the visual (and its auth UI) up.
241		err = o.opt.Visual.Detach()
242	} else {
243		err = o.opt.Visual.Close()
244	}
245	if err != nil {
246		o.log.Warn().Err(err).Msg("visual shutdown")
247	}
248}
249
250// handle feeds the policy and applies the resulting effects in order.
251func (o *Owner) handle(ctx context.Context, ev ports.PolicyEvent) error {
252	effects, err := o.policy.Handle(time.Now(), ev)
253	if err != nil {
254		o.log.Warn().Err(err).Msgf("policy rejected %T", ev)
255		return nil
256	}
257	for _, e := range effects {
258		if err := o.apply(ctx, e); err != nil {
259			return err
260		}
261	}
262	return nil
263}
264
265func (o *Owner) apply(ctx context.Context, effect ports.PolicyEffect) error {
266	switch e := effect.(type) {
267	case ports.ArmIdle:
268		regs := map[uint64]ports.Generation{}
269		origins := map[uint64]time.Time{}
270		var ds []time.Duration
271		now := time.Now()
272		for _, n := range e.Notifications {
273			g, ok := o.regGen[n.ID]
274			if !ok {
275				g = e.Generation // retained registrations keep their admitting generation
276			}
277			regs[n.ID] = g
278			origins[n.ID] = now
279			if t, ok := o.regOrigin[n.ID]; ok {
280				origins[n.ID] = t
281			}
282			if !slices.Contains(ds, n.After) {
283				ds = append(ds, n.After)
284			}
285		}
286		fired := map[uint64]bool{}
287		for id := range regs {
288			fired[id] = o.fired[id]
289		}
290		o.regGen, o.regOrigin, o.regs, o.fired = regs, origins, e.Notifications, fired
291		for _, m := range o.idle {
292			for d := range m {
293				if !slices.Contains(ds, d) {
294					delete(m, d) // adapter destroys that proxy; a re-add starts fresh
295				}
296			}
297		}
298		o.armDeferred()
299		// Always sent, even when deadlines are unchanged: the adapter stamps
300		// idle events with the newest generation it was told about, and it
301		// retains proxies (no rebase) when durations are unchanged.
302		o.wq = append(o.wq, ports.SetDeadlines{Generation: uint64(e.Generation), Deadlines: ds})
303	case ports.AcquireLock:
304		o.lockGen = e.Generation
305		o.delay = o.opt.RetryMin
306		return o.startLock(ctx)
307	case ports.SetFade:
308		if err := o.opt.Visual.Fade(ctx, ports.VisualFade{Black: e.Black, Duration: e.Duration, Spawn: o.spawn()}); err != nil {
309			o.log.Warn().Err(err).Bool("black", e.Black).Msg("fade request failed")
310		}
311	case ports.SetOutputPower:
312		o.wq = append(o.wq, ports.OutputPower(e))
313	case ports.Suspend:
314		o.sq = append(o.sq, ports.SystemSuspend{})
315	case ports.SleepReady:
316		if o.sleeping {
317			o.sq = append(o.sq, ports.SystemSleepReady{Cycle: o.sleepCycle})
318		}
319	}
320	return nil
321}
322
323// spawn omits both the current and the startup-captured pin-env names: the
324// startup value is what the daemon holds, whatever a reload later selects.
325func (o *Owner) spawn() ports.VisualSpawn {
326	var names []string
327	for _, a := range []ports.AuthConfig{o.cfg.Auth, o.startAuth} {
328		if a.PINEnv != "" && !slices.Contains(names, a.PINEnv) {
329			names = append(names, a.PINEnv)
330		}
331	}
332	return ports.VisualSpawn{OmitEnv: names}
333}
334
335func (o *Owner) bootstrap() ports.AuthBootstrap {
336	a := o.cfg.Auth
337	b := ports.AuthBootstrap{Generation: uint64(o.lockGen), PINSource: a.PINSource}
338	switch a.PINSource {
339	case ports.PINSourceEnv:
340		if v, ok := o.opt.LookupEnv(a.PINEnv); ok {
341			b.EnvPIN = []byte(v)
342		}
343	case ports.PINSourcePass:
344		b.PINReference = a.PINEntry
345	}
346	return b
347}
348
349func (o *Owner) lockWanted() bool {
350	st := o.policy.State()
351	return st.Acquiring || st.Protected
352}
353
354// startLock asks the visual to hold ext-session-lock for the lock generation,
355// (re)spawning it as needed. Failures back off and retry; the compositor keeps
356// any acquired lock in the meantime.
357func (o *Owner) startLock(ctx context.Context) error {
358	if !o.lockWanted() || o.lockGen == 0 {
359		return nil
360	}
361	err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Output: o.cfg.LockOutput, Spawn: o.spawn()})
362	if err != nil {
363		o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")
364		o.armRetry()
365	}
366	return nil
367}
368
369func (o *Owner) armRetry() {
370	o.retry.Reset(o.delay)
371	o.delay = min(o.delay*2, o.opt.RetryMax)
372}
373
374func (o *Owner) reload(ctx context.Context, cfg ports.Config) error {
375	if err := cfg.Validate(); err != nil {
376		o.log.Warn().Err(err).Msg("config reload rejected")
377		return nil
378	}
379	w, s := Requirements(cfg)
380	if w.OutputPower && !o.startWL.OutputPower || s.Sleep && !o.startSY.Sleep {
381		o.log.Warn().Msg("reload enables output-off or sleep support that was not initialised at startup; restart the daemon")
382	}
383	if o.gated && o.cfg.SleepAfter == 0 {
384		if w := suspendWarning(cfg, o.sysCaps); w != "" {
385			o.log.Warn().Msg(w)
386		}
387	}
388	o.cfg = cfg
389	return o.handle(ctx, ports.Reload{Config: cfg})
390}
391
392func (o *Owner) wayland(ctx context.Context, ev ports.WaylandEvent) error {
393	switch v := ev.(type) {
394	case ports.WaylandIdle:
395		// The adapter keeps one proxy per distinct duration, measured from the
396		// last input, while policy registrations (one per origin) are relative
397		// to their own registration time. A registration is delivered once its
398		// duration is idle and its origin+After has passed; later ones wait on
399		// the deferred timer (this is what keeps an equal-valued reload from
400		// rebasing early). Policy drops anything stale.
401		if o.idle[v.Seat] == nil {
402			o.idle[v.Seat] = map[time.Duration]bool{}
403		}
404		o.idle[v.Seat][v.After] = true
405		return o.fireDue(ctx)
406	case ports.WaylandActivity:
407		// Undoes fade and wakes outputs; the policy has no unlock path.
408		// Only the active seat stops being idle; other seats keep their state.
409		delete(o.idle, v.Seat)
410		clear(o.fired)
411		return o.handle(ctx, ports.Activity{})
412	case ports.WaylandCapabilities:
413		c := v.Capabilities
414		if o.gated && o.wlCaps != nil && len(c.Missing) > 0 {
415			o.log.Warn().Strs("missing", c.Missing).Msg("wayland capabilities degraded")
416		}
417		if !o.gated || o.wlCaps == nil {
418			// Until the gate passes the latest report counts: a compositor may
419			// advertise outputs after the daemon connected.
420			o.wlCaps = &c
421		}
422		o.seats, o.seatsKnown = map[uint32]bool{}, true
423		for _, id := range c.Seats {
424			o.seats[id] = true
425		}
426		for id := range o.idle {
427			if !o.seats[id] {
428				delete(o.idle, id) // removed seat no longer counts
429			}
430		}
431		return o.fireDue(ctx)
432	case ports.WaylandPowerFailed:
433		o.log.Warn().Uint64("output", v.Output).Msg("output power control failed")
434	}
435	return nil
436}
437
438// allIdle reports whether every known seat has duration d idle. Known seats are
439// the latest capability report's; nothing fires before the first report.
440func (o *Owner) allIdle(d time.Duration) bool {
441	if !o.seatsKnown || len(o.seats) == 0 {
442		return false
443	}
444	for id := range o.seats {
445		if !o.idle[id][d] {
446			return false
447		}
448	}
449	return true
450}
451
452// fireDue delivers every registration whose duration is idle on all known
453// seats and whose origin+After has passed, and re-arms the deferred timer.
454func (o *Owner) fireDue(ctx context.Context) error {
455	now := time.Now()
456	for _, n := range slices.Clone(o.regs) {
457		if !o.allIdle(n.After) || o.fired[n.ID] || o.regOrigin[n.ID].Add(n.After).After(now) {
458			continue
459		}
460		o.fired[n.ID] = true
461		if err := o.handle(ctx, ports.IdleDue{Generation: o.regGen[n.ID], Notification: n.ID, After: n.After}); err != nil {
462			return err
463		}
464	}
465	o.armDeferred()
466	return nil
467}
468
469func (o *Owner) armDeferred() {
470	o.deferred.Stop()
471	var next time.Time
472	for _, n := range o.regs {
473		if !o.allIdle(n.After) || o.fired[n.ID] {
474			continue
475		}
476		if t := o.regOrigin[n.ID].Add(n.After); next.IsZero() || t.Before(next) {
477			next = t
478		}
479	}
480	if !next.IsZero() {
481		o.deferred.Reset(max(time.Until(next), time.Millisecond))
482	}
483}
484
485func (o *Owner) system(ctx context.Context, ev ports.SystemEvent) error {
486	switch v := ev.(type) {
487	case ports.SystemCapabilityReport:
488		c := v.Capabilities
489		if o.sysCaps == nil {
490			o.sysCaps = &c
491		}
492	case ports.SystemLockRequested:
493		return o.handle(ctx, ports.LockRequested{})
494	case ports.SystemUnlockObserved:
495		// An observation, never authority: only the visual releases the lock.
496		o.log.Info().Msg("logind unlock observed; ignored")
497	case ports.SleepPreparation:
498		o.sleeping, o.sleepCycle = v.Preparing, v.Cycle
499		if v.Preparing {
500			return o.handle(ctx, ports.BeforeSleep{})
501		}
502	case ports.SystemFailure:
503		o.log.Warn().Err(v.Err).Str("operation", v.Operation).Msg("system operation failed")
504	}
505	return nil
506}
507
508func (o *Owner) visual(ctx context.Context, ev ports.VisualEvent) error {
509	switch v := ev.(type) {
510	case ports.LockConfirmed:
511		o.delay = o.opt.RetryMin
512		return o.handle(ctx, v)
513	case ports.LockReleased:
514		if err := o.handle(ctx, v); err != nil {
515			return err
516		}
517		if !o.lockWanted() {
518			o.lockGen = 0
519			o.retry.Stop()
520		}
521	case ports.VisualExited:
522		if o.lockWanted() {
523			// A dead lock visual leaves the session locked; bring it back.
524			o.log.Warn().Err(v.Err).Uint64("generation", uint64(o.lockGen)).Msg("lock visual exited; respawning")
525			o.armRetry()
526		} else {
527			o.log.Info().Err(v.Err).Msg("visual exited")
528		}
529	}
530	return nil
531}
532
533func (o *Owner) control(ctx context.Context, req ports.ControlRequest) error {
534	if req.Reply == nil {
535		return nil // nobody to answer, and nothing to apply
536	}
537	if req.Kind == ports.ControlLock {
538		if err := o.handle(ctx, ports.LockRequested{}); err != nil {
539			return err
540		}
541	} else if req.Kind != ports.ControlStatus {
542		req.Reply <- ports.ControlReply{Err: fmt.Sprintf("unknown request %d", req.Kind)}
543		return nil
544	}
545	st := o.policy.State()
546	req.Reply <- ports.ControlReply{Generation: st.Generation, Acquiring: st.Acquiring, Protected: st.Protected, Faded: st.Faded, Off: st.Off}
547	return nil
548}

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 2

Tags 9

↑↓ moveenter openesc close