package headless import ( "bufio" "context" "fmt" "image" "image/png" "os" "path/filepath" "sync" "time" "golang.org/x/sys/unix" "github.com/bnema/neferwl/internal/adapters/clock" "github.com/bnema/neferwl/internal/adapters/outputkit/capture" "github.com/bnema/neferwl/internal/adapters/outputkit/corewake" "github.com/bnema/neferwl/internal/adapters/outputkit/presented" "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" "github.com/bnema/neferwl/internal/adapters/syncfile" "github.com/bnema/neferwl/internal/ports" "github.com/bnema/zerowrap" ) type Options struct { // Security is the defensive gate. SecurityChanges wakes an idle owner; // the snapshot, not the queued notification payload, is authoritative. Security ports.SessionSecurity SecurityChanges <-chan ports.SecurityState Instance ports.OutputInstance SecurityEvents chan<- ports.SecurityBackendEvent // Cursor, when set, is drawn into screenshots with the image from // LoadCursor at the scene scale and transform. The cursor is drawn in // target space: the image comes rotated and Move takes target pixels. Cursor *Cursor LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) Width, Height int ScreenshotDir string HDR bool // virtual HDR output for protocol testing only // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir. RawHDR bool Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting Log zerowrap.Logger NewRenderer func(w, h int) (ports.Renderer, error) // NewCaptureRenderer makes the child renderer of a hidden workspace // capture session (Scene.CaptureScene). Nil: such captures fail closed. NewCaptureRenderer func(w, h int) (ports.Renderer, error) // Name and Presented report what the output has read after each // frame, so wayland can release client buffers (nil: no reports). Name string Presented ports.PresentationSink // Frames tells core a presented scene is animating: its next frame is // due. Sends never block (nil: nobody is told). Frames chan<- ports.OutputFrame Captured chan<- ports.CaptureDone // Clock paces the renderer trim (nil: system clock). Clock ports.Clock } // trimEvery is how often an output lets its renderer free the buffers of // windows it no longer draws. const trimEvery = 10 * time.Second func 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 { r, err := opts.NewRenderer(opts.Width, opts.Height) if err != nil { return fmt.Errorf("create renderer: %w", err) } defer r.Close() var confirmed *ports.OutputHDR if opts.HDR { r.SetHDR(203) if bufs, err := r.ExportTargets(1, nil, false); err != nil { opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR") r.SetHDR(0) _, _ = r.ExportTargets(0, nil, false) } else { for _, b := range bufs { for _, p := range b.Planes { _ = p.File.Close() } } confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005} opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled") } if opts.Formats != nil { select { case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}: case <-ctx.Done(): return nil } } } table := surfaces.New() drawn := table.Map() var scene ports.Scene haveScene, dirty := false, false frame := 0 var index *os.File var indexWriter *bufio.Writer var lastShot time.Time var lastShotName string defer func() { if index != nil { // A following file makes concat honor the final held interval. if !lastShot.IsZero() { _, _ = fmt.Fprintf(indexWriter, "duration %.6f\nfile '%s'\n", time.Since(lastShot).Seconds(), lastShotName) _ = indexWriter.Flush() } _ = index.Close() } }() var requests capture.Requests ctx, cancelCaptures := context.WithCancel(ctx) requests.Init(ctx, opts.Captured) pipeline := capture.NewPipeline(ctx, opts.Captured) pipeline.Security = opts.Security if opts.NewCaptureRenderer != nil { pipeline.EnableOffscreen(opts.NewCaptureRenderer) } defer func() { // An error path out of a gated frame: what it held is failed. pipeline.EndGate(capture.ErrFrameNotPresented) cancelCaptures() pipeline.Close(r) requests.Stop(incoming) }() var want ports.CursorChange cursorScale := -1.0 // not loaded yet var cursorTransform ports.BufferTransform seen := map[ports.WindowID]uint64{} // reports queues what the output read; every report is pushed to the // sink at once (Push never blocks), so it holds one at most. var reports presented.Queue var shows flipShows var security ports.SecurityState securityKnown := false failRequests := func(reason error) { // Whatever this frame had handed to a worker is failed with it. pipeline.EndGate(capture.GateVerdict(reason)) requests.FailPending(reason) } // A transition is its own frame, never a locker-buffer-dependent frame. // Keep its epoch through the fence wait and report only actual completion. checkSecurity := func() error { if opts.Security == nil { return nil } for { next := opts.Security.Snapshot() if securityKnown && next == security { return nil } // No hardware scanout exists: an already applied Off scene is // confirmed software inactivity, independent of its old epoch. inactive := haveScene && scene.Off security, securityKnown = next, true reports.Reset() haveScene, dirty = false, false if !security.Protected { continue } black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"} black.Off = inactive scene = black kind := ports.ProtectionInactiveOutput if !inactive { kind = ports.ProtectionProtectedFrame done, err := r.Render(black, nil) if err != nil { return fmt.Errorf("render protection: %w", err) } if done != nil { err = syncfile.Wait(ctx, done) _ = done.Close() if err != nil { return fmt.Errorf("protection fence: %w", err) } } } if opts.Security.Snapshot() != security { continue } if opts.SecurityEvents != nil { proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}} select { case opts.SecurityEvents <- proof: case <-ctx.Done(): return ctx.Err() } } // Capture reply backpressure must not postpone the first black // completion/proof. Capture drainage is a separate parent barrier. failRequests(capture.ErrSessionProtected) } } acceptScene := func(s ports.Scene) { if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) { return } // The core keeps the Seq of a scene that draws the same: nothing to do. if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security { return } scene, haveScene, dirty = s, true, true // A closed window's last content goes once no scene draws it. table.Prune(scene) } update := func(b *ports.ContentBatch) { batchDirty, _ := table.Apply(b, scene, seen, returns) dirty = dirty || batchDirty } clk := opts.Clock if clk == nil { clk = clock.System{} } trim := clk.NewTicker(trimEvery) defer trim.Stop() // One timer for the wait of held requests, reset per pass (a time.After // per loop pass would leave a timer behind each). holdTimer := time.NewTimer(time.Hour) holdTimer.Stop() defer holdTimer.Stop() for { // A frame that left its gate open (any path out but its present) fails // what it held: nothing is delivered for a frame that did not present. pipeline.EndGate(capture.ErrFrameNotPresented) if ctx.Err() != nil { return nil } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } // Requests wait for a scene that shows their indicator, at most // capture.HoldFor (see capture.Hold). var holdDue <-chan time.Time if requests.Len() > 0 { var wait time.Duration requests.List, wait = pipeline.Expire(scene, requests.List, time.Now()) if wait > 0 { holdTimer.Reset(wait) holdDue = holdTimer.C } } if holdDue == nil { holdTimer.Stop() } select { case <-holdDue: continue case <-ctx.Done(): return nil case _, ok := <-opts.SecurityChanges: if !ok { opts.SecurityChanges = nil } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } continue case b := <-pipeline.Completed(): // The completed token has left the pipeline's queue. Return its // lease before any transition path can error or block. pipeline.Recycle(b, r) if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } continue case b := <-pipeline.HiddenCompleted(): pipeline.RecycleHidden(b) if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } pipeline.Retire(scene) continue case q, ok := <-incoming: if !ok { incoming = nil continue } if err := checkSecurity(); err != nil { // q has left incoming but is not yet owned by requests/workers. capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured) if ctx.Err() != nil { return nil } return err } var refuse error if opts.protected() { refuse = capture.ErrSessionProtected } else if scene.Off { refuse = capture.ErrOutputOff } dirty = requests.Admit(q, refuse, scene, haveScene) || dirty case <-trim.C(): if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } // An idle output renders nothing: free what windows that left // it held, and the child of an ended session. pipeline.Retire(scene) if err := r.Trim(clk.Now()); err != nil { return fmt.Errorf("trim renderer: %w", err) } opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats") continue case s, ok := <-scenes: if !ok { scenes = nil continue } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } acceptScene(s) case b, ok := <-contents: if !ok { contents = nil continue } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } update(b) case c, ok := <-cursor: if !ok { cursor = nil continue } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } // The cursor is only drawn into screenshots: no new frame. want, cursorScale, cursorTransform = c, -1, 0 } // Drain all queued updates before presenting one frame. drain: for { select { case s, ok := <-scenes: if !ok { scenes = nil } else { if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } acceptScene(s) } case b, ok := <-contents: if !ok { contents = nil } else { if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } update(b) } default: break drain } } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } if opts.protected() { failRequests(capture.ErrSessionProtected) } if scene.Off && requests.Len() > 0 { requests.FailPending(capture.ErrOutputOff) } if !haveScene || !dirty || scene.Off { // Contents not drawn are still read: report them. An output // turned off draws nothing. opts.report(&reports, nil, seen) continue } dirty = false if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) { cursorScale, cursorTransform = scene.Scale, scene.Transform if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil { if !opts.protected() { opts.Cursor.set(img) } } else { opts.Log.Warn().Err(err).Msg("cursor") } } if err := checkSecurity(); err != nil { if ctx.Err() != nil { return nil } return err } if !haveScene { continue } start := time.Now() // Requests the scene cannot serve (exclusion of another session, a // workspace that moved) are failed here; see capture.Pipeline.Split. // A request whose indicator the scene does not show yet stays held. ready, _ := pipeline.Hold(scene, requests.List, time.Now()) if len(scene.CaptureIndicators) > 0 { // The captures of this frame are delivered only once its frame, // with the indicator, is presented (capture.Pipeline.EndGate). pipeline.BeginGate() } normal, excluded, hidden := pipeline.Split(scene, ready) pipeline.Retire(scene) if len(hidden) > 0 { // A hidden workspace: a child renderer draws it. The display's // fences do not cover the child, so wait for it before reporting. if opts.Security != nil && opts.Security.Snapshot() != scene.Security { failRequests(capture.ErrEpochChanged) continue } pipeline.SubmitHidden(scene, drawn, hidden) clear(hidden) // A slow child may outlive Wayland's stale-report timeout even // though this owner waits. Publish its non-expiring holds first. if opts.Presented != nil { _, reads, _ := pipeline.CapHiddenSeen(seen) if len(reads) > 0 { reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) reports.Flush(opts.Presented) } } if err := pipeline.WaitHidden(ctx); err != nil { if ctx.Err() != nil { return nil } return fmt.Errorf("workspace frame fence: %w", err) } } // Captures never show the capture indicator or the position hint, and // show hint labels only to clients with hint.capture: their frame goes // first. took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal) if pdone != nil { // The plain render reads client buffers even when its requests // are rejected by a gate transition: own and wait its fence // before any Seen report or protection check can proceed. waitErr := syncfile.Wait(ctx, pdone) _ = pdone.Close() if waitErr != nil { if ctx.Err() != nil { return nil } return fmt.Errorf("plain frame fence: %w", waitErr) } } if perr != nil { return fmt.Errorf("render plain frame: %w", perr) } if took { normal = nil // answered from the frame without the indicator } if len(excluded) > 0 { // Exclude requests: the frame without the excluded surfaces is // drawn first (same renderer, queue order), then the displayed one. // It has no indicator or position hint; it keeps hint labels with // hint.capture (Split refuses internal requests there). if opts.Security != nil && opts.Security.Snapshot() != scene.Security { failRequests(capture.ErrEpochChanged) continue } xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn) if xdone != nil { // The excluded-frame composition reads client buffers even when the displayed // frame is skipped by a gate/off transition. Own and wait its // fence before any Seen report or protection check can proceed. waitErr := syncfile.Wait(ctx, xdone) _ = xdone.Close() if waitErr != nil { if ctx.Err() != nil { return nil } return fmt.Errorf("excluded frame fence: %w", waitErr) } } if renderErr != nil { return fmt.Errorf("render excluded frame: %w", renderErr) } if opts.Security != nil && opts.Security.Snapshot() != scene.Security { failRequests(capture.ErrEpochChanged) continue } pipeline.SubmitScoped(scene.Security, r, excluded) clear(excluded) } if opts.Security != nil && opts.Security.Snapshot() != scene.Security { failRequests(capture.ErrEpochChanged) continue } done, err := r.Render(scene, drawn) if err != nil { return fmt.Errorf("render frame: %w", err) } if done != nil { // No screen paces headless frames: wait for the GPU before // reporting that its buffers were read. err = syncfile.Wait(ctx, done) done.Close() if err != nil { if ctx.Err() != nil { return nil } return fmt.Errorf("frame fence: %w", err) } } if len(normal) > 0 { opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed") } // Do not capture or report a frame that crossed a transition during // Render/fence wait. The next loop establishes the new black proof. if opts.Security != nil && opts.Security.Snapshot() != scene.Security { failRequests(capture.ErrEpochChanged) continue } pipeline.SubmitScoped(scene.Security, r, normal) // The indicated frame is presented: release what it gated. pipeline.EndGate(nil) requests.List = capture.Waiting(requests.List, scene) frame++ opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen) if scene.Animating { corewake.Send(opts.Frames, opts.Name) } if opts.ScreenshotDir != "" && !opts.protected() { shot := r.Pixels() if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { continue } if shot == nil { opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped") continue } if opts.Cursor != nil { opts.Cursor.drawSecure(shot, opts.Security) } if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { continue } name := fmt.Sprintf("frame-%06d.png", frame) written, err := writePNGSecureResult(filepath.Join(opts.ScreenshotDir, name), shot, opts.Security, scene.Security) if err != nil { return fmt.Errorf("screenshot: %w", err) } if !written { continue } // latest.png is the frame just written, linked rather than encoded again. written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) if err != nil { return fmt.Errorf("latest screenshot: %w", err) } if !written { continue } now := time.Now() if index == nil { index, err = os.OpenFile(filepath.Join(opts.ScreenshotDir, "frames.ffconcat"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("frame index: %w", err) } indexWriter = bufio.NewWriter(index) _, _ = fmt.Fprintln(indexWriter, "ffconcat version 1.0") } // Complete the previous frame's duration before starting this one. // An idle scene leaves that frame displayed and adds no entries. if !lastShot.IsZero() { _, _ = fmt.Fprintf(indexWriter, "duration %.6f\n", now.Sub(lastShot).Seconds()) } _, _ = fmt.Fprintf(indexWriter, "file '%s'\n", name) if err := indexWriter.Flush(); err != nil { return fmt.Errorf("frame index: %w", err) } lastShot, lastShotName = now, name if opts.RawHDR && opts.HDR { // No cursor on the raw image. Only a confirmed HDR output // has a readable PQ target; else HDRPixels is nil. if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { continue } raw := r.HDRPixels() if raw == nil { opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped") continue } if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil { return fmt.Errorf("raw HDR screenshot: %w", err) } } } if ev := opts.Log.Debug(); ev.Enabled() { ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame") } } } func (opts Options) protected() bool { return opts.Security != nil && opts.Security.Snapshot().Protected } func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { _, err := writePNGSecureResult(path, img, security, state) return err } // screenshotPNG encodes the PNGs written by the headless output (frames, the // latest.png fallback, raw HDR latest-pq.png): encoding is by far the largest // cost of a headless frame, so it trades file size for speed. Its buffers are // reused between files. var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)} // pngBuffers is a png.EncoderBufferPool. type pngBuffers struct{ pool sync.Pool } func (p *pngBuffers) Get() *png.EncoderBuffer { b, _ := p.pool.Get().(*png.EncoderBuffer) return b } func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) } // gated reports whether a screenshot must not be published: the session is // protected, or its security state is no longer the one the frame was drawn in. func gated(security ports.SessionSecurity, state ports.SecurityState) bool { return security != nil && (state.Protected || security.Snapshot() != state) } // writePNGSecureResult distinguishes a security skip from a published PNG. func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { if gated(security, state) { return false, nil } f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") if err != nil { return false, err } defer os.Remove(f.Name()) if err := screenshotPNG.Encode(f, img); err != nil { _ = f.Close() return false, err } if err := f.Close(); err != nil { return false, err } if gated(security, state) { return false, nil } if err := os.Rename(f.Name(), path); err != nil { return false, err } return true, nil } // linkPNGSecure publishes the PNG already written at src as dst without // encoding it again: a hard link under a temporary name, renamed over dst, so // readers see the old or the new frame, never a partial one. Frame files are // never modified after they are published, which makes the shared file safe. // It gates on security like writePNGSecureResult, and falls back to encoding // img where the file system cannot link. func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { if gated(security, state) { return false, nil } tmp := dst + ".tmp" _ = os.Remove(tmp) if err := os.Link(src, tmp); err != nil { return writePNGSecureResult(dst, img, security, state) } defer os.Remove(tmp) if gated(security, state) { return false, nil } if err := os.Rename(tmp, dst); err != nil { return false, err } return true, nil } // report queues what the output read, with flip for a drawn frame, and // pushes it to the sink. func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { if opts.Presented == nil { return } q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) q.Flush(opts.Presented) } // flipShows builds the Shows of successive flips. Its owner goroutine is the // only user. The Shows snapshot is shared with earlier flips of the same // content (see surfaces.ShowsSnapshot): never modify it. type flipShows struct { snapshot surfaces.ShowsSnapshot } // of returns the content Seq of the windows scene showed to their client. func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 { // A closed window drawn from its kept content is not shown to its // client. return f.snapshot.FromContents(scene, contents, kept) } // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC // time (software clock, refresh unknown), so presentation feedback and // frame callbacks follow headless frames. The FlipInfo itself is new per // flip (the sink counts merges on it); only its Shows is shared. func (f *flipShows) flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo { var ts unix.Timespec _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)} }