output.go RawHistoryFolder

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

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