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