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/corewake"
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 batchDirty, _ := table.Apply(b, scene, seen, returns)
197 dirty = dirty || batchDirty
198 }
199 clk := opts.Clock
200 if clk == nil {
201 clk = clock.System{}
202 }
203 trim := clk.NewTicker(trimEvery)
204 defer trim.Stop()
205 // One timer for the wait of held requests, reset per pass (a time.After
206 // per loop pass would leave a timer behind each).
207 holdTimer := time.NewTimer(time.Hour)
208 holdTimer.Stop()
209 defer holdTimer.Stop()
210 for {
211 // A frame that left its gate open (any path out but its present) fails
212 // what it held: nothing is delivered for a frame that did not present.
213 pipeline.EndGate(capture.ErrFrameNotPresented)
214 if ctx.Err() != nil {
215 return nil
216 }
217 if err := checkSecurity(); err != nil {
218 if ctx.Err() != nil {
219 return nil
220 }
221 return err
222 }
223 // Requests wait for a scene that shows their indicator, at most
224 // capture.HoldFor (see capture.Hold).
225 var holdDue <-chan time.Time
226 if requests.Len() > 0 {
227 var wait time.Duration
228 requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
229 if wait > 0 {
230 holdTimer.Reset(wait)
231 holdDue = holdTimer.C
232 }
233 }
234 if holdDue == nil {
235 holdTimer.Stop()
236 }
237 select {
238 case <-holdDue:
239 continue
240 case <-ctx.Done():
241 return nil
242 case _, ok := <-opts.SecurityChanges:
243 if !ok {
244 opts.SecurityChanges = nil
245 }
246 if err := checkSecurity(); err != nil {
247 if ctx.Err() != nil {
248 return nil
249 }
250 return err
251 }
252 continue
253 case b := <-pipeline.Completed():
254 // The completed token has left the pipeline's queue. Return its
255 // lease before any transition path can error or block.
256 pipeline.Recycle(b, r)
257 if err := checkSecurity(); err != nil {
258 if ctx.Err() != nil {
259 return nil
260 }
261 return err
262 }
263 continue
264 case b := <-pipeline.HiddenCompleted():
265 pipeline.RecycleHidden(b)
266 if err := checkSecurity(); err != nil {
267 if ctx.Err() != nil {
268 return nil
269 }
270 return err
271 }
272 pipeline.Retire(scene)
273 continue
274 case q, ok := <-incoming:
275 if !ok {
276 incoming = nil
277 continue
278 }
279 if err := checkSecurity(); err != nil {
280 // q has left incoming but is not yet owned by requests/workers.
281 capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
282 if ctx.Err() != nil {
283 return nil
284 }
285 return err
286 }
287 var refuse error
288 if opts.protected() {
289 refuse = capture.ErrSessionProtected
290 } else if scene.Off {
291 refuse = capture.ErrOutputOff
292 }
293 dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
294 case <-trim.C():
295 if err := checkSecurity(); err != nil {
296 if ctx.Err() != nil {
297 return nil
298 }
299 return err
300 }
301 // An idle output renders nothing: free what windows that left
302 // it held, and the child of an ended session.
303 pipeline.Retire(scene)
304 if err := r.Trim(clk.Now()); err != nil {
305 return fmt.Errorf("trim renderer: %w", err)
306 }
307 opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats")
308 continue
309 case s, ok := <-scenes:
310 if !ok {
311 scenes = nil
312 continue
313 }
314 if err := checkSecurity(); err != nil {
315 if ctx.Err() != nil {
316 return nil
317 }
318 return err
319 }
320 acceptScene(s)
321 case b, ok := <-contents:
322 if !ok {
323 contents = nil
324 continue
325 }
326 if err := checkSecurity(); err != nil {
327 if ctx.Err() != nil {
328 return nil
329 }
330 return err
331 }
332 update(b)
333 case c, ok := <-cursor:
334 if !ok {
335 cursor = nil
336 continue
337 }
338 if err := checkSecurity(); err != nil {
339 if ctx.Err() != nil {
340 return nil
341 }
342 return err
343 }
344 // The cursor is only drawn into screenshots: no new frame.
345 want, cursorScale, cursorTransform = c, -1, 0
346 }
347 // Drain all queued updates before presenting one frame.
348 drain:
349 for {
350 select {
351 case s, ok := <-scenes:
352 if !ok {
353 scenes = nil
354 } else {
355 if err := checkSecurity(); err != nil {
356 if ctx.Err() != nil {
357 return nil
358 }
359 return err
360 }
361 acceptScene(s)
362 }
363 case b, ok := <-contents:
364 if !ok {
365 contents = nil
366 } else {
367 if err := checkSecurity(); err != nil {
368 if ctx.Err() != nil {
369 return nil
370 }
371 return err
372 }
373 update(b)
374 }
375 default:
376 break drain
377 }
378 }
379 if err := checkSecurity(); err != nil {
380 if ctx.Err() != nil {
381 return nil
382 }
383 return err
384 }
385 if opts.protected() {
386 failRequests(capture.ErrSessionProtected)
387 }
388 if scene.Off && requests.Len() > 0 {
389 requests.FailPending(capture.ErrOutputOff)
390 }
391 if !haveScene || !dirty || scene.Off {
392 // Contents not drawn are still read: report them. An output
393 // turned off draws nothing.
394 opts.report(&reports, nil, seen)
395 continue
396 }
397 dirty = false
398 if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) {
399 cursorScale, cursorTransform = scene.Scale, scene.Transform
400 if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil {
401 if !opts.protected() {
402 opts.Cursor.set(img)
403 }
404 } else {
405 opts.Log.Warn().Err(err).Msg("cursor")
406 }
407 }
408 if err := checkSecurity(); err != nil {
409 if ctx.Err() != nil {
410 return nil
411 }
412 return err
413 }
414 if !haveScene {
415 continue
416 }
417 start := time.Now()
418 // Requests the scene cannot serve (exclusion of another session, a
419 // workspace that moved) are failed here; see capture.Pipeline.Split.
420 // A request whose indicator the scene does not show yet stays held.
421 ready, _ := pipeline.Hold(scene, requests.List, time.Now())
422 if len(scene.CaptureIndicators) > 0 {
423 // The captures of this frame are delivered only once its frame,
424 // with the indicator, is presented (capture.Pipeline.EndGate).
425 pipeline.BeginGate()
426 }
427 normal, excluded, hidden := pipeline.Split(scene, ready)
428 pipeline.Retire(scene)
429 if len(hidden) > 0 {
430 // A hidden workspace: a child renderer draws it. The display's
431 // fences do not cover the child, so wait for it before reporting.
432 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
433 failRequests(capture.ErrEpochChanged)
434 continue
435 }
436 pipeline.SubmitHidden(scene, drawn, hidden)
437 clear(hidden)
438 // A slow child may outlive Wayland's stale-report timeout even
439 // though this owner waits. Publish its non-expiring holds first.
440 if opts.Presented != nil {
441 _, reads, _ := pipeline.CapHiddenSeen(seen)
442 if len(reads) > 0 {
443 reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
444 reports.Flush(opts.Presented)
445 }
446 }
447 if err := pipeline.WaitHidden(ctx); err != nil {
448 if ctx.Err() != nil {
449 return nil
450 }
451 return fmt.Errorf("workspace frame fence: %w", err)
452 }
453 }
454 // Captures never show the capture indicator: their frame goes first.
455 took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
456 if pdone != nil {
457 // The plain render reads client buffers even when its requests
458 // are rejected by a gate transition: own and wait its fence
459 // before any Seen report or protection check can proceed.
460 waitErr := syncfile.Wait(ctx, pdone)
461 _ = pdone.Close()
462 if waitErr != nil {
463 if ctx.Err() != nil {
464 return nil
465 }
466 return fmt.Errorf("plain frame fence: %w", waitErr)
467 }
468 }
469 if perr != nil {
470 return fmt.Errorf("render plain frame: %w", perr)
471 }
472 if took {
473 normal = nil // answered from the frame without the indicator
474 }
475 if len(excluded) > 0 {
476 // Exclude requests: the frame without the excluded surfaces is
477 // drawn first (same renderer, queue order), then the displayed one.
478 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
479 failRequests(capture.ErrEpochChanged)
480 continue
481 }
482 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
483 if xdone != nil {
484 // The excluded-frame composition reads client buffers even when the displayed
485 // frame is skipped by a gate/off transition. Own and wait its
486 // fence before any Seen report or protection check can proceed.
487 waitErr := syncfile.Wait(ctx, xdone)
488 _ = xdone.Close()
489 if waitErr != nil {
490 if ctx.Err() != nil {
491 return nil
492 }
493 return fmt.Errorf("excluded frame fence: %w", waitErr)
494 }
495 }
496 if renderErr != nil {
497 return fmt.Errorf("render excluded frame: %w", renderErr)
498 }
499 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
500 failRequests(capture.ErrEpochChanged)
501 continue
502 }
503 pipeline.SubmitScoped(scene.Security, r, excluded)
504 clear(excluded)
505 }
506 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
507 failRequests(capture.ErrEpochChanged)
508 continue
509 }
510 done, err := r.Render(scene, drawn)
511 if err != nil {
512 return fmt.Errorf("render frame: %w", err)
513 }
514 if done != nil {
515 // No screen paces headless frames: wait for the GPU before
516 // reporting that its buffers were read.
517 err = syncfile.Wait(ctx, done)
518 done.Close()
519 if err != nil {
520 if ctx.Err() != nil {
521 return nil
522 }
523 return fmt.Errorf("frame fence: %w", err)
524 }
525 }
526 if len(normal) > 0 {
527 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
528 }
529 // Do not capture or report a frame that crossed a transition during
530 // Render/fence wait. The next loop establishes the new black proof.
531 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
532 failRequests(capture.ErrEpochChanged)
533 continue
534 }
535 pipeline.SubmitScoped(scene.Security, r, normal)
536 // The indicated frame is presented: release what it gated.
537 pipeline.EndGate(nil)
538 requests.List = capture.Waiting(requests.List, scene)
539 frame++
540 opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen)
541 if scene.Animating {
542 corewake.Send(opts.Frames, opts.Name)
543 }
544 if opts.ScreenshotDir != "" && !opts.protected() {
545 shot := r.Pixels()
546 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
547 continue
548 }
549 if shot == nil {
550 opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
551 continue
552 }
553 if opts.Cursor != nil {
554 opts.Cursor.drawSecure(shot, opts.Security)
555 }
556 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
557 continue
558 }
559 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
560 return fmt.Errorf("screenshot: %w", err)
561 }
562 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
563 return fmt.Errorf("latest screenshot: %w", err)
564 }
565 if opts.RawHDR && opts.HDR {
566 // No cursor on the raw image. Only a confirmed HDR output
567 // has a readable PQ target; else HDRPixels is nil.
568 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
569 continue
570 }
571 raw := r.HDRPixels()
572 if raw == nil {
573 opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
574 continue
575 }
576 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
577 return fmt.Errorf("raw HDR screenshot: %w", err)
578 }
579 }
580 }
581 if ev := opts.Log.Debug(); ev.Enabled() {
582 ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
583 }
584 }
585}
586
587func (opts Options) protected() bool {
588 return opts.Security != nil && opts.Security.Snapshot().Protected
589}
590
591func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
592 if security != nil && (state.Protected || security.Snapshot() != state) {
593 return nil
594 }
595 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
596 if err != nil {
597 return err
598 }
599 defer os.Remove(f.Name())
600 if err := png.Encode(f, img); err != nil {
601 _ = f.Close()
602 return err
603 }
604 if err := f.Close(); err != nil {
605 return err
606 }
607 if security != nil && security.Snapshot() != state {
608 return nil
609 }
610 return os.Rename(f.Name(), path)
611}
612
613// report queues what the output read, with flip for a drawn frame, and
614// pushes it to the sink.
615func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
616 if opts.Presented == nil {
617 return
618 }
619 q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen})
620 q.Flush(opts.Presented)
621}
622
623// flipShows builds the Shows of successive flips. Its owner goroutine is the
624// only user. The Shows snapshot is shared with earlier flips of the same
625// content (see surfaces.ShowsSnapshot): never modify it.
626type flipShows struct {
627 snapshot surfaces.ShowsSnapshot
628}
629
630// of returns the content Seq of the windows scene showed to their client.
631func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
632 // A closed window drawn from its kept content is not shown to its
633 // client.
634 return f.snapshot.FromContents(scene, contents, kept)
635}
636
637// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
638// time (software clock, refresh unknown), so presentation feedback and
639// frame callbacks follow headless frames. The FlipInfo itself is new per
640// flip (the sink counts merges on it); only its Shows is shared.
641func (f *flipShows) 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 return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)}
645}