output.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"image"
8	"image/png"
9	"os"
10	"path/filepath"
11	"time"
12
13	"golang.org/x/sys/unix"
14
15	"github.com/bnema/neferwl/internal/adapters/capture"
16	"github.com/bnema/neferwl/internal/adapters/clock"
17	"github.com/bnema/neferwl/internal/adapters/presented"
18	"github.com/bnema/neferwl/internal/adapters/syncfile"
19	"github.com/bnema/neferwl/internal/ports"
20	"github.com/bnema/zerowrap"
21)
22
23type Options struct {
24	// Security is the defensive gate. SecurityChanges wakes an idle owner;
25	// the snapshot, not the queued notification payload, is authoritative.
26	Security        ports.SessionSecurity
27	SecurityChanges <-chan ports.SecurityState
28	Instance        ports.OutputInstance
29	SecurityEvents  chan<- ports.SecurityBackendEvent
30	// Cursor, when set, is drawn into screenshots with the image from
31	// LoadCursor at the scene scale.
32	Cursor        *Cursor
33	LoadCursor    func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
34	Width, Height int
35	ScreenshotDir string
36	HDR           bool                       // virtual HDR output for protocol testing only
37	Formats       chan<- ports.OutputFormats // confirmed color state; nil disables reporting
38	Log           zerowrap.Logger
39	NewRenderer   func(w, h int) (ports.Renderer, error)
40	// NewCaptureRenderer makes the child renderer of a hidden workspace
41	// capture session (Scene.CaptureScene). Nil: such captures fail closed.
42	NewCaptureRenderer func(w, h int) (ports.Renderer, error)
43	// Name and Presented report what the output has read after each
44	// frame, so wayland can release client buffers (nil: no reports).
45	Name      string
46	Presented chan<- ports.OutputPresented
47	Captured  chan<- ports.CaptureDone
48	// Clock paces the renderer trim (nil: system clock).
49	Clock ports.Clock
50}
51
52// trimEvery is how often an output lets its renderer free the buffers of
53// windows it no longer draws.
54const trimEvery = 10 * time.Second
55
56func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
57	r, err := opts.NewRenderer(opts.Width, opts.Height)
58	if err != nil {
59		return fmt.Errorf("create renderer: %w", err)
60	}
61	defer r.Close()
62	var confirmed *ports.OutputHDR
63	if opts.HDR {
64		r.SetHDR(203)
65		if bufs, err := r.ExportTargets(1, nil); err != nil {
66			opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
67			r.SetHDR(0)
68			_, _ = r.ExportTargets(0, nil)
69		} else {
70			for _, b := range bufs {
71				for _, p := range b.Planes {
72					_ = p.File.Close()
73				}
74			}
75			confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}
76			opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled")
77		}
78		if opts.Formats != nil {
79			select {
80			case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}:
81			case <-ctx.Done():
82				return nil
83			}
84		}
85	}
86	surfaces := make(map[ports.WindowID]ports.SurfaceContent)
87	var scene ports.Scene
88	haveScene, dirty := false, false
89	frame := 0
90	var requestStorage [capture.MaxRequests]ports.CaptureRequest
91	requests := requestStorage[:0]
92	ctx, cancelCaptures := context.WithCancel(ctx)
93	pipeline := capture.NewPipeline(ctx, opts.Captured)
94	pipeline.Security = opts.Security
95	if opts.NewCaptureRenderer != nil {
96		pipeline.EnableOffscreen(opts.NewCaptureRenderer)
97	}
98	defer func() {
99		// An error path out of a gated frame: what it held is failed.
100		pipeline.EndGate(capture.ErrFrameNotPresented)
101		cancelCaptures()
102		pipeline.Close(r)
103		for _, q := range requests {
104			if capture.Handed(q) {
105				continue // already answered, or owned by a worker
106			}
107			capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
108		}
109		for {
110			select {
111			case q, ok := <-incoming:
112				if !ok {
113					return
114				}
115				capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
116			default:
117				return
118			}
119		}
120	}()
121	var want ports.CursorChange
122	cursorScale := -1.0 // not loaded yet
123	seen := map[ports.WindowID]uint64{}
124	// reports holds what the channel could not take yet, retried soon.
125	var reports presented.Queue
126	var security ports.SecurityState
127	securityKnown := false
128	failRequests := func(reason string) {
129		// Whatever this frame had handed to a worker is failed with it.
130		pipeline.EndGate(capture.GateVerdict(errors.New(reason)))
131		for _, q := range requests {
132			if !capture.Handed(q) {
133				capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
134			}
135		}
136		clear(requests)
137		requests = requestStorage[:0]
138	}
139	// A transition is its own frame, never a locker-buffer-dependent frame.
140	// Keep its epoch through the fence wait and report only actual completion.
141	checkSecurity := func() error {
142		if opts.Security == nil {
143			return nil
144		}
145		for {
146			next := opts.Security.Snapshot()
147			if securityKnown && next == security {
148				return nil
149			}
150			// No hardware scanout exists: an already applied Off scene is
151			// confirmed software inactivity, independent of its old epoch.
152			inactive := haveScene && scene.Off
153			security, securityKnown = next, true
154			reports.Reset()
155			haveScene, dirty = false, false
156			if !security.Protected {
157				continue
158			}
159			black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"}
160			black.Off = inactive
161			scene = black
162			kind := ports.ProtectionInactiveOutput
163			if !inactive {
164				kind = ports.ProtectionProtectedFrame
165				done, err := r.Render(black, nil)
166				if err != nil {
167					return fmt.Errorf("render protection: %w", err)
168				}
169				if done != nil {
170					err = syncfile.Wait(ctx, done)
171					_ = done.Close()
172					if err != nil {
173						return fmt.Errorf("protection fence: %w", err)
174					}
175				}
176			}
177			if opts.Security.Snapshot() != security {
178				continue
179			}
180			if opts.SecurityEvents != nil {
181				proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}}
182				select {
183				case opts.SecurityEvents <- proof:
184				case <-ctx.Done():
185					return ctx.Err()
186				}
187			}
188			// Capture reply backpressure must not postpone the first black
189			// completion/proof. Capture drainage is a separate parent barrier.
190			failRequests("session protected")
191		}
192	}
193	acceptScene := func(s ports.Scene) {
194		if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
195			return
196		}
197		scene, haveScene, dirty = s, true, true
198	}
199	update := func(c ports.SurfaceContent) {
200		seen[c.ID] = max(seen[c.ID], c.Seq)
201		dirty = dirty || capture.Shows(scene, c.ID)
202		if c.Empty() {
203			delete(surfaces, c.ID)
204		} else {
205			surfaces[c.ID] = c
206		}
207	}
208	clk := opts.Clock
209	if clk == nil {
210		clk = clock.System{}
211	}
212	trim := clk.NewTicker(trimEvery)
213	defer trim.Stop()
214	// One timer for the wait of held requests, reset per pass (a time.After
215	// per loop pass would leave a timer behind each).
216	holdTimer := time.NewTimer(time.Hour)
217	holdTimer.Stop()
218	defer holdTimer.Stop()
219	for {
220		// A frame that left its gate open (any path out but its present) fails
221		// what it held: nothing is delivered for a frame that did not present.
222		pipeline.EndGate(capture.ErrFrameNotPresented)
223		if ctx.Err() != nil {
224			return nil
225		}
226		if err := checkSecurity(); err != nil {
227			if ctx.Err() != nil {
228				return nil
229			}
230			return err
231		}
232		var retry <-chan time.Time
233		if reports.Len() > 0 {
234			retry = time.After(time.Millisecond)
235		}
236		// Requests wait for a scene that shows their indicator, at most
237		// capture.HoldFor (see capture.Hold).
238		var holdDue <-chan time.Time
239		if len(requests) > 0 {
240			var wait time.Duration
241			requests, wait = pipeline.Expire(scene, requests, time.Now())
242			if wait > 0 {
243				holdTimer.Reset(wait)
244				holdDue = holdTimer.C
245			}
246		}
247		if holdDue == nil {
248			holdTimer.Stop()
249		}
250		select {
251		case <-holdDue:
252			continue
253		case <-ctx.Done():
254			return nil
255		case _, ok := <-opts.SecurityChanges:
256			if !ok {
257				opts.SecurityChanges = nil
258			}
259			if err := checkSecurity(); err != nil {
260				if ctx.Err() != nil {
261					return nil
262				}
263				return err
264			}
265			continue
266		case b := <-pipeline.Completed():
267			// The completed token has left the pipeline's queue. Return its
268			// lease before any transition path can error or block.
269			pipeline.Recycle(b, r)
270			if err := checkSecurity(); err != nil {
271				if ctx.Err() != nil {
272					return nil
273				}
274				return err
275			}
276			continue
277		case b := <-pipeline.HiddenCompleted():
278			pipeline.RecycleHidden(b)
279			if err := checkSecurity(); err != nil {
280				if ctx.Err() != nil {
281					return nil
282				}
283				return err
284			}
285			pipeline.Retire(scene)
286			continue
287		case q, ok := <-incoming:
288			if !ok {
289				incoming = nil
290				continue
291			}
292			if err := checkSecurity(); err != nil {
293				// q has left incoming but is not yet owned by requests/workers.
294				capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
295				if ctx.Err() != nil {
296					return nil
297				}
298				return err
299			}
300			if opts.protected() {
301				capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)
302			} else if scene.Off {
303				capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
304			} else if len(requests) == cap(requests) {
305				capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
306			} else {
307				q.Since = time.Now()
308				requests = append(requests, q)
309				// Its indicator may already be on screen; else it waits for the
310				// scene that shows it.
311				dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
312			}
313		case <-retry:
314			if err := checkSecurity(); err != nil {
315				if ctx.Err() != nil {
316					return nil
317				}
318				return err
319			}
320			opts.flush(&reports)
321			continue
322		case <-trim.C():
323			if err := checkSecurity(); err != nil {
324				if ctx.Err() != nil {
325					return nil
326				}
327				return err
328			}
329			// An idle output renders nothing: free what windows that left
330			// it held, and the child of an ended session.
331			pipeline.Retire(scene)
332			if err := r.Trim(clk.Now()); err != nil {
333				return fmt.Errorf("trim renderer: %w", err)
334			}
335			continue
336		case s, ok := <-scenes:
337			if !ok {
338				scenes = nil
339				continue
340			}
341			if err := checkSecurity(); err != nil {
342				if ctx.Err() != nil {
343					return nil
344				}
345				return err
346			}
347			acceptScene(s)
348		case c, ok := <-contents:
349			if !ok {
350				contents = nil
351				continue
352			}
353			if err := checkSecurity(); err != nil {
354				if ctx.Err() != nil {
355					return nil
356				}
357				return err
358			}
359			update(c)
360		case c, ok := <-cursor:
361			if !ok {
362				cursor = nil
363				continue
364			}
365			if err := checkSecurity(); err != nil {
366				if ctx.Err() != nil {
367					return nil
368				}
369				return err
370			}
371			// The cursor is only drawn into screenshots: no new frame.
372			want, cursorScale = c, -1
373		}
374		// Drain all queued updates before presenting one frame.
375	drain:
376		for {
377			select {
378			case s, ok := <-scenes:
379				if !ok {
380					scenes = nil
381				} else {
382					if err := checkSecurity(); err != nil {
383						if ctx.Err() != nil {
384							return nil
385						}
386						return err
387					}
388					acceptScene(s)
389				}
390			case c, ok := <-contents:
391				if !ok {
392					contents = nil
393				} else {
394					if err := checkSecurity(); err != nil {
395						if ctx.Err() != nil {
396							return nil
397						}
398						return err
399					}
400					update(c)
401				}
402			default:
403				break drain
404			}
405		}
406		if err := checkSecurity(); err != nil {
407			if ctx.Err() != nil {
408				return nil
409			}
410			return err
411		}
412		if opts.protected() {
413			failRequests("session protected")
414		}
415		if scene.Off && len(requests) > 0 {
416			for _, q := range requests {
417				capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
418			}
419			clear(requests)
420			requests = requestStorage[:0]
421		}
422		if !haveScene || !dirty || scene.Off {
423			// Contents not drawn are still read: report them. An output
424			// turned off draws nothing.
425			opts.report(&reports, nil, seen)
426			continue
427		}
428		dirty = false
429		if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
430			cursorScale = scene.Scale
431			if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {
432				if !opts.protected() {
433					opts.Cursor.set(img)
434				}
435			} else {
436				opts.Log.Warn().Err(err).Msg("cursor")
437			}
438		}
439		if err := checkSecurity(); err != nil {
440			if ctx.Err() != nil {
441				return nil
442			}
443			return err
444		}
445		if !haveScene {
446			continue
447		}
448		start := time.Now()
449		// Requests the scene cannot serve (exclusion of another session, a
450		// workspace that moved) are failed here; see capture.Pipeline.Split.
451		// A request whose indicator the scene does not show yet stays held.
452		ready, _ := pipeline.Hold(scene, requests, time.Now())
453		if len(scene.CaptureIndicators) > 0 {
454			// The captures of this frame are delivered only once its frame,
455			// with the indicator, is presented (capture.Pipeline.EndGate).
456			pipeline.BeginGate()
457		}
458		normal, excluded, hidden := pipeline.Split(scene, ready)
459		pipeline.Retire(scene)
460		if len(hidden) > 0 {
461			// A hidden workspace: a child renderer draws it. The display's
462			// fences do not cover the child, so wait for it before reporting.
463			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
464				failRequests("security epoch changed")
465				continue
466			}
467			pipeline.SubmitHidden(scene, surfaces, hidden)
468			clear(hidden)
469			// A slow child may outlive Wayland's stale-report timeout even
470			// though this owner waits. Publish its non-expiring holds first.
471			if opts.Presented != nil {
472				_, reads, _ := pipeline.CapHiddenSeen(seen)
473				if len(reads) > 0 {
474					reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
475					if reports.Drain(ctx, opts.Presented) != nil {
476						return nil
477					}
478				}
479			}
480			if err := pipeline.WaitHidden(ctx); err != nil {
481				if ctx.Err() != nil {
482					return nil
483				}
484				return fmt.Errorf("workspace frame fence: %w", err)
485			}
486		}
487		// Captures never show the capture indicator: their frame goes first.
488		took, pdone, perr := pipeline.SubmitPlain(r, scene, surfaces, normal)
489		if pdone != nil {
490			// The plain render reads client buffers even when its requests
491			// are rejected by a gate transition: own and wait its fence
492			// before any Seen report or protection check can proceed.
493			waitErr := syncfile.Wait(ctx, pdone)
494			_ = pdone.Close()
495			if waitErr != nil {
496				if ctx.Err() != nil {
497					return nil
498				}
499				return fmt.Errorf("plain frame fence: %w", waitErr)
500			}
501		}
502		if perr != nil {
503			return fmt.Errorf("render plain frame: %w", perr)
504		}
505		if took {
506			normal = nil // answered from the frame without the indicator
507		}
508		if len(excluded) > 0 {
509			// Exclude requests: the frame without the excluded surfaces is
510			// drawn first (same renderer, queue order), then the displayed one.
511			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
512				failRequests("security epoch changed")
513				continue
514			}
515			xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces)
516			if xdone != nil {
517				// The excluded-frame composition reads client buffers even when the displayed
518				// frame is skipped by a gate/off transition. Own and wait its
519				// fence before any Seen report or protection check can proceed.
520				waitErr := syncfile.Wait(ctx, xdone)
521				_ = xdone.Close()
522				if waitErr != nil {
523					if ctx.Err() != nil {
524						return nil
525					}
526					return fmt.Errorf("excluded frame fence: %w", waitErr)
527				}
528			}
529			if renderErr != nil {
530				return fmt.Errorf("render excluded frame: %w", renderErr)
531			}
532			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
533				failRequests("security epoch changed")
534				continue
535			}
536			pipeline.SubmitScoped(scene.Security, r, excluded)
537			clear(excluded)
538		}
539		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
540			failRequests("security epoch changed")
541			continue
542		}
543		done, err := r.Render(scene, surfaces)
544		if err != nil {
545			return fmt.Errorf("render frame: %w", err)
546		}
547		if done != nil {
548			// No screen paces headless frames: wait for the GPU before
549			// reporting that its buffers were read.
550			err = syncfile.Wait(ctx, done)
551			done.Close()
552			if err != nil {
553				if ctx.Err() != nil {
554					return nil
555				}
556				return fmt.Errorf("frame fence: %w", err)
557			}
558		}
559		if len(normal) > 0 {
560			opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
561		}
562		// Do not capture or report a frame that crossed a transition during
563		// Render/fence wait. The next loop establishes the new black proof.
564		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
565			failRequests("security epoch changed")
566			continue
567		}
568		pipeline.SubmitScoped(scene.Security, r, normal)
569		// The indicated frame is presented: release what it gated.
570		pipeline.EndGate(nil)
571		requests = capture.Waiting(requests, scene)
572		frame++
573		opts.report(&reports, flipInfo(scene, surfaces), seen)
574		if opts.ScreenshotDir != "" && !opts.protected() {
575			shot := r.Pixels()
576			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
577				continue
578			}
579			if shot == nil {
580				opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
581				continue
582			}
583			if opts.Cursor != nil {
584				opts.Cursor.drawSecure(shot, opts.Security)
585			}
586			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
587				continue
588			}
589			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
590				return fmt.Errorf("screenshot: %w", err)
591			}
592			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
593				return fmt.Errorf("latest screenshot: %w", err)
594			}
595		}
596		opts.Log.Debug().Str("component", "render").Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
597	}
598}
599
600func (opts Options) protected() bool {
601	return opts.Security != nil && opts.Security.Snapshot().Protected
602}
603
604func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error {
605	if security != nil && (state.Protected || security.Snapshot() != state) {
606		return nil
607	}
608	f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
609	if err != nil {
610		return err
611	}
612	defer os.Remove(f.Name())
613	if err := png.Encode(f, img); err != nil {
614		_ = f.Close()
615		return err
616	}
617	if err := f.Close(); err != nil {
618		return err
619	}
620	if security != nil && security.Snapshot() != state {
621		return nil
622	}
623	return os.Rename(f.Name(), path)
624}
625
626// report queues what the output read, with flip for a drawn frame, and
627// sends what the channel takes now; the rest is retried soon.
628func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
629	if opts.Presented == nil {
630		return
631	}
632	if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {
633		opts.Log.Warn().Str("component", "render").Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
634	}
635	q.Flush(opts.Presented)
636}
637
638// flush retries reports the channel could not take.
639func (opts Options) flush(q *presented.Queue) {
640	if opts.Presented != nil {
641		q.Flush(opts.Presented)
642	}
643}
644
645// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
646// time (software clock, refresh unknown), so presentation feedback and
647// frame callbacks follow headless frames.
648func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo {
649	var ts unix.Timespec
650	_ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
651	shows := map[ports.WindowID]uint64{}
652	for id, c := range surfaces {
653		if scene.Shows(id) {
654			shows[id] = c.Seq
655		}
656	}
657	return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}
658}

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 25

Tags 10

↑↓ moveenter openesc close