v0.2.0 13c6a90

offscreen.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close