1package capture
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "image"
8 "maps"
9 "math"
10 "os"
11
12 "github.com/bnema/neferwl/internal/adapters/syncfile"
13 "github.com/bnema/neferwl/internal/ports"
14 "golang.org/x/sys/unix"
15)
16
17// Offscreen capture: a session of a workspace that is not on screen is drawn
18// by a dedicated child renderer from Scene.CaptureScene, only for requests
19// of that workspace and never shown. The child is a separate renderer instance: it has
20// its own Vulkan device, so its fences are not ordered against the display's.
21// It is created on the first request of the workspace and closed when the
22// scene stops carrying CaptureScene (once idle), when the workspace or the frame
23// size changes, and with the output.
24
25const (
26 // MaxOffscreenDim bounds either side of the child image, in pixels.
27 MaxOffscreenDim = 16384
28 // MaxOffscreenBytes bounds one child image (w*h*4).
29 MaxOffscreenBytes = 256 << 20
30 // maxChildFences bounds child renders whose GPU reads are not finished.
31 // It equals the renderer's frame slots (two): a third Render would make
32 // the owner wait for the GPU, so it is refused before it is recorded.
33 maxChildFences = 2
34)
35
36var (
37 // ErrOffscreenUnavailable fails a request whose source is a hidden
38 // workspace that this output cannot draw (no child renderer factory).
39 ErrOffscreenUnavailable = errors.New("workspace capture source is unavailable")
40 // ErrOffscreenGeometry fails a request the child frame cannot serve.
41 ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported")
42 // ErrOffscreenBusy fails a request while the child is still busy with
43 // another session or size, or has too many renders in flight.
44 ErrOffscreenBusy = errors.New("workspace capture is busy")
45)
46
47// fenceSlot is one child render whose GPU reads may not be finished: its sync
48// file and the read ceiling it implies. ceil holds, for each window the render
49// drew, the content Seq the renderer was given. The map is reused for the
50// output's lifetime (cleared and refilled), so a steady state allocates
51// nothing.
52type fenceSlot struct {
53 fd *os.File
54 ceil map[ports.WindowID]uint64
55}
56
57// release closes the fence and forgets the ceiling, keeping the map.
58func (s *fenceSlot) release() {
59 if s.fd != nil {
60 s.fd.Close()
61 s.fd = nil
62 }
63 clear(s.ceil)
64}
65
66// offscreen is owned by the goroutine of its Pipeline.
67type offscreen struct {
68 factory func(w, h int) (ports.Renderer, error)
69 r ports.Renderer
70 p *Pipeline // leases of r
71 w, h int
72 workspace uint64
73 // slots[:nf] are the unfinished child renders, oldest first.
74 slots [maxChildFences]fenceSlot
75 nf int
76 // capped is the scratch map CapHiddenSeen returns.
77 capped map[ports.WindowID]uint64
78 // reads is the scratch map of the oldest outstanding child reads.
79 reads map[ports.WindowID]uint64
80}
81
82// EnableOffscreen lets the pipeline serve requests of a hidden
83// workspace with renderers made by factory. Without it they fail closed
84// with ErrOffscreenUnavailable.
85func (p *Pipeline) EnableOffscreen(factory func(w, h int) (ports.Renderer, error)) {
86 p.off = &offscreen{factory: factory}
87}
88
89// HiddenCompleted wakes the owner when a child capture finished; nil (blocks
90// forever in a select) while there is no child.
91func (p *Pipeline) HiddenCompleted() <-chan *batch {
92 if p == nil || p.off == nil || p.off.p == nil {
93 return nil
94 }
95 return p.off.p.completed
96}
97
98// RecycleHidden returns a completed child lease.
99func (p *Pipeline) RecycleHidden(b *batch) {
100 if p != nil && p.off != nil && p.off.p != nil {
101 p.off.p.Recycle(b, p.off.r)
102 }
103}
104
105// HiddenReading reports whether the child may still read client buffers. The
106// display's fences do not cover it.
107func (p *Pipeline) HiddenReading() bool {
108 if p == nil || p.off == nil {
109 return false
110 }
111 p.off.reap()
112 return p.off.nf > 0
113}
114
115// CapHiddenSeen limits seen, the content Seq per window an output has read,
116// to what the unfinished child renders were given: a client buffer newer than
117// that may not be released yet on their account. Only windows a render drew
118// are capped, each by the lowest ceiling among the unfinished renders that
119// drew it (a window the render drew without content is capped at 0); every
120// other window is untouched, so the display's own reads are never held back.
121// The second result records the oldest outstanding read per window, even
122// when Seen needs no cap: those buffers must not be released by a stale-report
123// timeout. The last result says whether child reads remain, so the owner keeps
124// reporting until they finish. Both maps are pipeline scratch, valid until
125// the next call; copy them to keep them.
126func (p *Pipeline) CapHiddenSeen(seen map[ports.WindowID]uint64) (map[ports.WindowID]uint64, map[ports.WindowID]uint64, bool) {
127 if p == nil || p.off == nil {
128 return seen, nil, false
129 }
130 o := p.off
131 o.reap()
132 if o.nf == 0 {
133 return seen, nil, false
134 }
135 if o.reads == nil {
136 o.reads = make(map[ports.WindowID]uint64)
137 }
138 clear(o.reads)
139 hit := false
140 for i := range o.nf {
141 for id, c := range o.slots[i].ceil {
142 if prev, ok := o.reads[id]; !ok || c < prev {
143 o.reads[id] = c
144 }
145 if seen[id] > c {
146 hit = true
147 }
148 }
149 }
150 if !hit {
151 return seen, o.reads, true
152 }
153 if o.capped == nil {
154 o.capped = make(map[ports.WindowID]uint64, len(seen))
155 }
156 clear(o.capped)
157 maps.Copy(o.capped, seen)
158 for i := range o.nf {
159 for id, c := range o.slots[i].ceil {
160 if v, ok := o.capped[id]; ok && v > c {
161 o.capped[id] = c
162 }
163 }
164 }
165 return o.capped, o.reads, true
166}
167
168// WaitHidden blocks until the child finished reading (headless outputs, which
169// have no screen pacing frames anyway). Whatever happens, every fence is
170// closed on return: after a failure or a cancelled ctx the rest is not waited
171// for (the renderer's Close waits for the device before it frees anything).
172func (p *Pipeline) WaitHidden(ctx context.Context) error {
173 if p == nil || p.off == nil {
174 return nil
175 }
176 o := p.off
177 var first error
178 for i := range o.nf {
179 if first == nil {
180 first = syncfile.Wait(ctx, o.slots[i].fd)
181 }
182 o.slots[i].release()
183 }
184 o.nf = 0
185 return first
186}
187
188// Retire closes the child once the scene no longer carries CaptureScene and
189// nothing of it is in flight. Call it when a frame is prepared, when a child
190// lease is recycled and on the idle housekeeping tick.
191func (p *Pipeline) Retire(s ports.Scene) {
192 if p != nil && p.off != nil && s.CaptureScene == nil && p.off.r != nil && !p.off.busy() {
193 p.off.close()
194 }
195}
196
197// fenceState is what a zero-timeout poll says about a child render fence.
198type fenceState int
199
200const (
201 fencePending fenceState = iota // GPU work still running (or poll interrupted)
202 fenceDone // finished: nothing is left to read
203 fenceFailed // terminal: the GPU failed the work or poll broke
204)
205
206// reap forgets the renders whose fence signalled, oldest first kept in order.
207// A terminal fence error cannot be waited out, and its hold does not expire:
208// the child pipeline is cancelled and joined, then the device is closed (which
209// waits for the GPU), and only then are the holds cleared, so every later
210// report lifts them. The next request builds a new child.
211func (o *offscreen) reap() {
212 k := 0
213 failed := false
214 for i := range o.nf {
215 switch pollFence(o.slots[i].fd) {
216 case fenceDone:
217 o.slots[i].release()
218 continue
219 case fenceFailed:
220 failed = true
221 }
222 if k != i {
223 o.slots[k], o.slots[i] = o.slots[i], o.slots[k]
224 }
225 k++
226 }
227 o.nf = k
228 if failed {
229 o.close()
230 }
231}
232
233// pollFence polls a sync file without waiting. It does not allocate.
234func pollFence(f *os.File) fenceState {
235 fd := int32(f.Fd())
236 if fd < 0 {
237 return fenceDone // closed: poll would skip it, and nothing is left to wait for
238 }
239 var pfd [1]unix.PollFd
240 pfd[0] = unix.PollFd{Fd: fd, Events: unix.POLLIN}
241 n, err := unix.Poll(pfd[:], 0)
242 return pollResult(n, err, pfd[0].Revents)
243}
244
245// pollResult decides from a zero-timeout poll. A readable fence (or an invalid
246// descriptor, which has nothing left to wait for) is done. An interrupted poll
247// and a timeout are pending. An error status (POLLERR: the GPU failed the
248// work) or any other poll error is terminal: it will never signal.
249func pollResult(n int, err error, revents int16) fenceState {
250 switch {
251 case errors.Is(err, unix.EBADF):
252 return fenceDone
253 case errors.Is(err, unix.EINTR):
254 return fencePending
255 case err != nil:
256 return fenceFailed
257 case n <= 0:
258 return fencePending
259 case revents&unix.POLLERR != 0:
260 return fenceFailed
261 case revents&(unix.POLLIN|unix.POLLNVAL) != 0:
262 return fenceDone
263 }
264 return fencePending
265}
266
267func (o *offscreen) busy() bool {
268 o.reap()
269 if o.nf > 0 {
270 return true
271 }
272 if o.p != nil {
273 o.p.reclaim(o.r)
274 for i := range o.p.batches {
275 if o.p.batches[i].frame != nil {
276 return true
277 }
278 }
279 }
280 return false
281}
282
283func (o *offscreen) close() {
284 if o.p != nil {
285 o.p.Close(o.r)
286 o.p = nil
287 }
288 if o.r != nil {
289 o.r.Close()
290 o.r = nil
291 }
292 for i := range o.slots {
293 o.slots[i].release()
294 }
295 o.nf, o.w, o.h, o.workspace = 0, 0, 0, 0
296}
297
298// childSize is the physical child image: the workspace frame at the scene
299// scale, rounded up.
300func childSize(cs *ports.Scene) (w, h int, err error) {
301 scale := cs.Scale
302 if scale <= 0 {
303 scale = 1
304 }
305 fw, fh := math.Ceil(float64(cs.OutputWidth)*scale), math.Ceil(float64(cs.OutputHeight)*scale)
306 if !(fw >= 1 && fh >= 1 && fw <= MaxOffscreenDim && fh <= MaxOffscreenDim) || fw*fh*4 > MaxOffscreenBytes {
307 return 0, 0, fmt.Errorf("%w: frame %.0fx%.0f", ErrOffscreenGeometry, fw, fh)
308 }
309 return int(fw), int(fh), nil
310}
311
312// track records the ceiling of a child render on the next free slot: every
313// window and layer the render drew, with the content Seq it was given.
314func (o *offscreen) track(done *os.File, drawn ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
315 slot := &o.slots[o.nf]
316 if slot.ceil == nil {
317 slot.ceil = make(map[ports.WindowID]uint64, len(drawn.Windows)+len(drawn.Layers))
318 }
319 clear(slot.ceil)
320 for _, w := range drawn.Windows {
321 slot.ceil[w.ID] = surfaces[w.ID].Seq
322 }
323 for _, l := range drawn.Layers {
324 slot.ceil[l.ID] = surfaces[l.ID].Seq
325 }
326 slot.fd = done
327 o.nf++
328}
329
330// SubmitHidden serves the workspace requests of s from the child renderer, which
331// draws s.CaptureScene without the capture indicator. It consumes
332// every request: answered by the worker, or failed here (the display is not
333// stopped by a child failure). Request regions are physical output pixels and
334// must cover the whole child frame; they are rebased to its origin. Anything smaller needs the viewport
335// origin in output coordinates, which the scene does not carry, and fails with
336// ErrOffscreenGeometry.
337func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, reqs []ports.CaptureRequest) {
338 fail := func(err error, qs []ports.CaptureRequest) {
339 for _, q := range qs {
340 Fail(p.ctx, q, err, p.replies)
341 }
342 }
343 o := p.off
344 switch {
345 case len(reqs) == 0:
346 return
347 case !p.captureAllowed(s.Security):
348 fail(ErrSecurityState, reqs)
349 return
350 case s.Off:
351 fail(errors.New("output off"), reqs)
352 return
353 case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0:
354 fail(ErrOffscreenUnavailable, reqs)
355 return
356 case o == nil || o.factory == nil:
357 fail(ErrOffscreenUnavailable, reqs)
358 return
359 case p.ctx.Err() != nil:
360 fail(p.ctx.Err(), reqs)
361 return
362 }
363 cs := s.CaptureScene
364 w, h, err := childSize(cs)
365 if err != nil {
366 fail(err, reqs)
367 return
368 }
369 if o.r != nil && (o.workspace != s.Capture.Workspace || o.w != w || o.h != h) {
370 if o.busy() {
371 fail(ErrOffscreenBusy, reqs)
372 return
373 }
374 o.close()
375 }
376 o.reap()
377 if o.nf == len(o.slots) {
378 // Refused before Render: a third frame would make the owner wait.
379 fail(ErrOffscreenBusy, reqs)
380 return
381 }
382 if o.r == nil {
383 r, err := o.factory(w, h)
384 if err != nil {
385 fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)
386 return
387 }
388 o.r, o.p, o.w, o.h, o.workspace = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Workspace
389 o.p.Security = p.Security
390 }
391 // Request regions are physical pixels of the output, and the scene does
392 // not carry the workspace viewport's origin in output coordinates, so they
393 // cannot place a crop. The child image is the whole frame: a request for
394 // all of it has the child's size and starts at the child's origin. A
395 // smaller region would need that origin; guessing it would return the
396 // wrong crop, so it fails.
397 k := 0
398 for _, q := range reqs {
399 if q.Region.Dx() != w || q.Region.Dy() != h {
400 Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies)
401 continue
402 }
403 q.Region = image.Rect(0, 0, w, h)
404 reqs[k] = q
405 k++
406 }
407 clear(reqs[k:])
408 reqs = reqs[:k]
409 if k == 0 {
410 return
411 }
412 // The child never shows the indicator; it has no layers, so nothing of
413 // an exclusion can be in it.
414 child := plain(*cs)
415 if !p.captureAllowed(s.Security) {
416 fail(ErrSecurityState, reqs)
417 return
418 }
419 done, err := o.r.Render(child, surfaces)
420 // This device's reads are independent of the display. Even a rejected
421 // capture retains its render fence/ceilings until GPU completion.
422 if done != nil {
423 o.track(done, child, surfaces)
424 }
425 if err != nil {
426 fail(fmt.Errorf("render workspace: %w", err), reqs)
427 return
428 }
429 o.p.gating = p.gating
430 // Immutable parent scene epoch, not the excluder's rewritten child scene.
431 // SubmitScoped rechecks immediately before native BeginCapture.
432 o.p.SubmitScoped(s.Security, o.r, reqs)
433}