1// Package capture transfers each request to an output: the output closes its
2// destination fd exactly once and attempts one reply, never blocking after
3// its context is done.
4package capture
5
6import (
7 "context"
8 "errors"
9 "fmt"
10 "runtime/debug"
11 "time"
12
13 "github.com/bnema/neferwl/internal/ports"
14 "golang.org/x/sys/unix"
15)
16
17const maxMappedBytes = 1 << 30
18
19// Wayland SHM format identifiers; all capture pixels are opaque BGRA.
20const (
21 shmARGB8888 = 0
22 shmXRGB8888 = 1
23)
24
25func reply(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
26 replyAt(ctx, req, err, monotonicNow(), replies)
27}
28
29func replyAt(ctx context.Context, req ports.CaptureRequest, err error, at time.Time, replies chan<- ports.CaptureDone) {
30 if req.Dst.File != nil {
31 err = errors.Join(err, req.Dst.File.Close())
32 }
33 done := ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: at}
34 // Prefer a ready reply even after output cancellation: Wayland needs the
35 // completion to release admission credit while the session is still alive.
36 select {
37 case replies <- done:
38 return
39 default:
40 }
41 // A nil channel is allowed: cancellation still releases the sender.
42 select {
43 case <-ctx.Done():
44 case replies <- done:
45 }
46}
47
48// monotonicNow represents CLOCK_MONOTONIC in the Time seconds/nanoseconds fields.
49func monotonicNow() time.Time {
50 var ts unix.Timespec
51 if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts); err != nil {
52 return time.Time{}
53 }
54 return time.Unix(ts.Sec, ts.Nsec)
55}
56
57func Fail(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
58 reply(ctx, req, err, replies)
59}
60
61func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.CaptureFrame, at time.Time, replies chan<- ports.CaptureDone) {
62 var err error
63 var data []byte
64 // A client can truncate its file after Stat while it is still mapped.
65 // Turn SIGBUS into an error on this worker, not a compositor crash.
66 defer debug.SetPanicOnFault(debug.SetPanicOnFault(true))
67 defer func() {
68 if p := recover(); p != nil {
69 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())
70 }
71 // data is only ever a mapping made here, never req.Mapped.
72 if data != nil {
73 err = errors.Join(err, unix.Munmap(data))
74 }
75 replyAt(ctx, req, err, at, replies)
76 }()
77 size, err := destSize(req)
78 if err != nil {
79 return
80 }
81 if req.Mapped != nil {
82 var dst []byte
83 if dst, err = mappedDst(req, size); err != nil {
84 return
85 }
86 err = frame.Read(req.Region, dst, req.Stride)
87 return
88 }
89 var st unix.Stat_t
90 if e := unix.Fstat(int(req.Dst.File.Fd()), &st); e != nil {
91 err = fmt.Errorf("fstat capture destination: %w", e)
92 return
93 }
94 if st.Size < size {
95 err = errTruncated
96 return
97 }
98 // Map only for the duration of this capture; the renderer copies BGRA rows
99 // directly from its readback buffer into the selected shm region.
100 data, err = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED)
101 if err != nil {
102 return
103 }
104 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)
105}
106
107// destSize validates the destination of req, a file or a borrowed mapping, and
108// returns the bytes it must hold from offset 0 through the last pixel.
109func destSize(req ports.CaptureRequest) (int64, error) {
110 if (req.Dst.File == nil) == (req.Mapped == nil) || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {
111 return 0, errInvalidDest
112 }
113 rowBytes := int64(req.Width) * 4
114 // Validate the bound before multiplication: padding must not wrap size.
115 if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
116 return 0, errTooLarge
117 }
118 size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes
119 if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {
120 return 0, errTooLarge
121 }
122 return size, nil
123}
124
125// mappedDst returns the borrowed mapping of req from the destination offset.
126// The output never maps, unmaps or closes it.
127func mappedDst(req ports.CaptureRequest, size int64) ([]byte, error) {
128 if int64(len(req.Mapped)) < size {
129 return nil, errTruncated
130 }
131 return req.Mapped[req.Dst.Offset:], nil
132}
133
134var (
135 errInvalidDest = errors.New("invalid capture destination")
136 errTooLarge = errors.New("capture too large")
137 errTruncated = errors.New("capture buffer truncated")
138)