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}