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