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

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