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