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, 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}

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