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}