fix(screencast): close streams on shutdown, scope screenshot jobs to the attempt, pause without ending
- Shutdown (context end or bus loss) pumps every stream, including those without a source yet: CastClose goes out, a frame in flight is awaited, then the source is destroyed and its buffers released. On timeout the leaked source keeps its handler state and the session table is replaced, so reconnects do not exhaust the cast and source tables. - Screenshot jobs get a per-attempt context, carry a job ID so a stale result completes nothing, and are cancelled and joined before the state is reset. - A paused PipeWire node (consumer left) gets no frames and keeps its session, as in Mutter; the first frame is forced on STREAMING. - A clean nefercap exit is a success only with a regular, non-empty file (Lstat, no symlink follow); otherwise the reply is (false, "").
- commit
- 4a05b71aa477724bc454405be9c9c85ce6780f1c
- author
- brice · · 2026-10-14 21:30 UTC
- committer
- brice · · 2026-10-14 21:39 UTC
- parent
- fe541fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +625 -78
M docs/capture.md +1 -1
| @@ -67,7 +67,7 @@ NeferWL serves the GNOME ScreenCast, DisplayConfig, Introspect and Screenshot D- | |||
| 67 | 67 | ||
| 68 | 68 | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-gnome` and run PipeWire. The portal dialog lists monitors and windows with their titles; the stream is a PipeWire video node. Without PipeWire, the ScreenCast interface is not offered. | |
| 69 | 69 | - **Screenshots** through the portal, interactive or not: install [nefercap](https://github.com/bnema/nefercap), which writes the PNG. | |
| 70 | - | - A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, or the client disconnects. | |
| 70 | + | - A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, the client stops it or disconnects, or NeferWL loses the D-Bus connection. When the receiving program stops reading, the stream pauses (no frames are rendered) and the cast stays open for the next reader, as in GNOME. | |
| 71 | 71 | - Unsupported: area and virtual casts, and the "Select area" and "Window" choices of the screenshot dialog. | |
| 72 | 72 | ||
| 73 | 73 | The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the gnome portal. | |
| @@ -67,7 +67,7 @@ NeferWL serves the GNOME ScreenCast, DisplayConfig, Introspect and Screenshot D- | |||||
| 67 | 67 | ||||
| 68 | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-gnome` and run PipeWire. The portal dialog lists monitors and windows with their titles; the stream is a PipeWire video node. Without PipeWire, the ScreenCast interface is not offered. | 68 | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-gnome` and run PipeWire. The portal dialog lists monitors and windows with their titles; the stream is a PipeWire video node. Without PipeWire, the ScreenCast interface is not offered. | ||
| 69 | - **Screenshots** through the portal, interactive or not: install [nefercap](https://github.com/bnema/nefercap), which writes the PNG. | 69 | - **Screenshots** through the portal, interactive or not: install [nefercap](https://github.com/bnema/nefercap), which writes the PNG. | ||
| 70 | - | - A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, or the client disconnects. | 70 | + | - A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, the client stops it or disconnects, or NeferWL loses the D-Bus connection. When the receiving program stops reading, the stream pauses (no frames are rendered) and the cast stays open for the next reader, as in GNOME. |
| 71 | - Unsupported: area and virtual casts, and the "Select area" and "Window" choices of the screenshot dialog. | 71 | - Unsupported: area and virtual casts, and the "Select area" and "Window" choices of the screenshot dialog. | ||
| 72 | 72 | ||||
| 73 | The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the gnome portal. | 73 | The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the gnome portal. | ||
M internal/adapters/screencast/harness_test.go +54 -11
| @@ -19,10 +19,11 @@ import ( | |||
| 19 | 19 | "github.com/stretchr/testify/mock" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | - | // privateBus starts a dbus-daemon of its own, so tests never touch the | |
| 23 | - | // desktop's session bus. CI must run these tests: there, a missing daemon | |
| 24 | - | // fails. | |
| 25 | - | func privateBus(t *testing.T) string { | |
| 22 | + | // startBus starts a dbus-daemon of its own, so tests never touch the | |
| 23 | + | // desktop's session bus, and returns its address and a function killing it, | |
| 24 | + | // which is how a test loses the bus. CI must run these tests: there, a | |
| 25 | + | // missing daemon fails. | |
| 26 | + | func startBus(t *testing.T) (string, func()) { | |
| 26 | 27 | t.Helper() | |
| 27 | 28 | bin, err := exec.LookPath("dbus-daemon") | |
| 28 | 29 | if err != nil { | |
| @@ -39,12 +40,13 @@ func privateBus(t *testing.T) string { | |||
| 39 | 40 | if err := cmd.Start(); err != nil { | |
| 40 | 41 | t.Fatal(err) | |
| 41 | 42 | } | |
| 42 | - | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |
| 43 | + | kill := func() { _ = cmd.Process.Kill() } | |
| 44 | + | t.Cleanup(func() { kill(); _ = cmd.Wait() }) | |
| 43 | 45 | line, err := bufio.NewReader(out).ReadString('\n') | |
| 44 | 46 | if err != nil { | |
| 45 | 47 | t.Fatal(err) | |
| 46 | 48 | } | |
| 47 | - | return strings.TrimSpace(line) | |
| 49 | + | return strings.TrimSpace(line), kill | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | 52 | // logBuf is a locked bytes.Buffer, a real io.Writer. | |
| @@ -78,6 +80,23 @@ type peers struct { | |||
| 78 | 80 | pid atomic.Int64 | |
| 79 | 81 | openErr error | |
| 80 | 82 | exePid func(open int) int // pid Exe reports; the opened one when nil | |
| 83 | + | ||
| 84 | + | // exeFile, when set, tells the executable of one pidfd: the pidfds are | |
| 85 | + | // numbered in the order Open handed them out (0 is the first peer). | |
| 86 | + | exeFile func(n int) string | |
| 87 | + | mu sync.Mutex | |
| 88 | + | opened []*os.File | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (p *peers) number(f *os.File) int { | |
| 92 | + | p.mu.Lock() | |
| 93 | + | defer p.mu.Unlock() | |
| 94 | + | for i, o := range p.opened { | |
| 95 | + | if o == f { | |
| 96 | + | return i | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | return -1 | |
| 81 | 100 | } | |
| 82 | 101 | ||
| 83 | 102 | // svcHarness runs a Service on a private bus with mocked executables and | |
| @@ -92,17 +111,23 @@ type svcHarness struct { | |||
| 92 | 111 | run *mockrunner | |
| 93 | 112 | log *logBuf | |
| 94 | 113 | done chan struct{} | |
| 114 | + | ||
| 115 | + | killBus func() // loses the bus: the daemon dies | |
| 116 | + | closed chan struct{} // a PipeWire connection was closed | |
| 95 | 117 | } | |
| 96 | 118 | ||
| 97 | 119 | type harnessOpts struct { | |
| 98 | 120 | allow *captureallow.Policy | |
| 99 | 121 | noPW bool | |
| 100 | 122 | holdOp bool | |
| 123 | + | // iterate runs inside every PipeWire loop iteration, on the owner. | |
| 124 | + | iterate func() | |
| 101 | 125 | } | |
| 102 | 126 | ||
| 103 | 127 | func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |
| 104 | 128 | t.Helper() | |
| 105 | - | h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})} | |
| 129 | + | h := &svcHarness{t: t, cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{}), closed: make(chan struct{}, 8)} | |
| 130 | + | h.addr, h.killBus = startBus(t) | |
| 106 | 131 | h.peers.exe.Store("/usr/bin/grim") | |
| 107 | 132 | if o.holdOp { | |
| 108 | 133 | h.peers.openGo = make(chan struct{}) | |
| @@ -123,14 +148,23 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |||
| 123 | 148 | return nil, h.peers.openErr | |
| 124 | 149 | } | |
| 125 | 150 | h.peers.pid.Store(int64(pid)) | |
| 126 | - | return os.Open(os.DevNull) | |
| 151 | + | f, err := os.Open(os.DevNull) | |
| 152 | + | if err == nil { | |
| 153 | + | h.peers.mu.Lock() | |
| 154 | + | h.peers.opened = append(h.peers.opened, f) | |
| 155 | + | h.peers.mu.Unlock() | |
| 156 | + | } | |
| 157 | + | return f, err | |
| 127 | 158 | }).Maybe() | |
| 128 | - | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) { | |
| 159 | + | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(f *os.File) (string, int, error) { | |
| 129 | 160 | open := int(h.peers.pid.Load()) | |
| 130 | 161 | pid := open | |
| 131 | 162 | if h.peers.exePid != nil { | |
| 132 | 163 | pid = h.peers.exePid(open) | |
| 133 | 164 | } | |
| 165 | + | if h.peers.exeFile != nil { | |
| 166 | + | return h.peers.exeFile(h.peers.number(f)), pid, nil | |
| 167 | + | } | |
| 134 | 168 | if h.peers.exeNow != nil { | |
| 135 | 169 | return h.peers.exeNow(), pid, nil | |
| 136 | 170 | } | |
| @@ -151,8 +185,17 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |||
| 151 | 185 | t.Cleanup(func() { r.Close(); w.Close() }) | |
| 152 | 186 | h.core = newMockvideoCore(t) | |
| 153 | 187 | h.core.EXPECT().Fd().Return(int(r.Fd())) | |
| 154 | - | h.core.EXPECT().Iterate().Maybe() | |
| 155 | - | h.core.EXPECT().Close().Return().Maybe() | |
| 188 | + | h.core.EXPECT().Iterate().Run(func() { | |
| 189 | + | if o.iterate != nil { | |
| 190 | + | o.iterate() | |
| 191 | + | } | |
| 192 | + | }).Maybe() | |
| 193 | + | h.core.EXPECT().Close().Run(func() { | |
| 194 | + | select { | |
| 195 | + | case h.closed <- struct{}{}: | |
| 196 | + | default: | |
| 197 | + | } | |
| 198 | + | }).Maybe() | |
| 156 | 199 | pwOpen.EXPECT().Open().Return(h.core, nil) | |
| 157 | 200 | } | |
| 158 | 201 | h.svc.pw = pwOpen | |
| @@ -19,10 +19,11 @@ import ( | |||||
| 19 | "github.com/stretchr/testify/mock" | 19 | "github.com/stretchr/testify/mock" | ||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | - | // privateBus starts a dbus-daemon of its own, so tests never touch the | 22 | + | // startBus starts a dbus-daemon of its own, so tests never touch the |
| 23 | - | // desktop's session bus. CI must run these tests: there, a missing daemon | 23 | + | // desktop's session bus, and returns its address and a function killing it, |
| 24 | - | // fails. | 24 | + | // which is how a test loses the bus. CI must run these tests: there, a |
| 25 | - | func privateBus(t *testing.T) string { | 25 | + | // missing daemon fails. |
| 26 | + | func startBus(t *testing.T) (string, func()) { | |||
| 26 | t.Helper() | 27 | t.Helper() | ||
| 27 | bin, err := exec.LookPath("dbus-daemon") | 28 | bin, err := exec.LookPath("dbus-daemon") | ||
| 28 | if err != nil { | 29 | if err != nil { | ||
| @@ -39,12 +40,13 @@ func privateBus(t *testing.T) string { | |||||
| 39 | if err := cmd.Start(); err != nil { | 40 | if err := cmd.Start(); err != nil { | ||
| 40 | t.Fatal(err) | 41 | t.Fatal(err) | ||
| 41 | } | 42 | } | ||
| 42 | - | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | 43 | + | kill := func() { _ = cmd.Process.Kill() } |
| 44 | + | t.Cleanup(func() { kill(); _ = cmd.Wait() }) | |||
| 43 | line, err := bufio.NewReader(out).ReadString('\n') | 45 | line, err := bufio.NewReader(out).ReadString('\n') | ||
| 44 | if err != nil { | 46 | if err != nil { | ||
| 45 | t.Fatal(err) | 47 | t.Fatal(err) | ||
| 46 | } | 48 | } | ||
| 47 | - | return strings.TrimSpace(line) | 49 | + | return strings.TrimSpace(line), kill |
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | // logBuf is a locked bytes.Buffer, a real io.Writer. | 52 | // logBuf is a locked bytes.Buffer, a real io.Writer. | ||
| @@ -78,6 +80,23 @@ type peers struct { | |||||
| 78 | pid atomic.Int64 | 80 | pid atomic.Int64 | ||
| 79 | openErr error | 81 | openErr error | ||
| 80 | exePid func(open int) int // pid Exe reports; the opened one when nil | 82 | exePid func(open int) int // pid Exe reports; the opened one when nil | ||
| 83 | + | ||||
| 84 | + | // exeFile, when set, tells the executable of one pidfd: the pidfds are | |||
| 85 | + | // numbered in the order Open handed them out (0 is the first peer). | |||
| 86 | + | exeFile func(n int) string | |||
| 87 | + | mu sync.Mutex | |||
| 88 | + | opened []*os.File | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (p *peers) number(f *os.File) int { | |||
| 92 | + | p.mu.Lock() | |||
| 93 | + | defer p.mu.Unlock() | |||
| 94 | + | for i, o := range p.opened { | |||
| 95 | + | if o == f { | |||
| 96 | + | return i | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | return -1 | |||
| 81 | } | 100 | } | ||
| 82 | 101 | ||||
| 83 | // svcHarness runs a Service on a private bus with mocked executables and | 102 | // svcHarness runs a Service on a private bus with mocked executables and | ||
| @@ -92,17 +111,23 @@ type svcHarness struct { | |||||
| 92 | run *mockrunner | 111 | run *mockrunner | ||
| 93 | log *logBuf | 112 | log *logBuf | ||
| 94 | done chan struct{} | 113 | done chan struct{} | ||
| 114 | + | ||||
| 115 | + | killBus func() // loses the bus: the daemon dies | |||
| 116 | + | closed chan struct{} // a PipeWire connection was closed | |||
| 95 | } | 117 | } | ||
| 96 | 118 | ||||
| 97 | type harnessOpts struct { | 119 | type harnessOpts struct { | ||
| 98 | allow *captureallow.Policy | 120 | allow *captureallow.Policy | ||
| 99 | noPW bool | 121 | noPW bool | ||
| 100 | holdOp bool | 122 | holdOp bool | ||
| 123 | + | // iterate runs inside every PipeWire loop iteration, on the owner. | |||
| 124 | + | iterate func() | |||
| 101 | } | 125 | } | ||
| 102 | 126 | ||||
| 103 | func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | 127 | func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | ||
| 104 | t.Helper() | 128 | t.Helper() | ||
| 105 | - | h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})} | 129 | + | h := &svcHarness{t: t, cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{}), closed: make(chan struct{}, 8)} |
| 130 | + | h.addr, h.killBus = startBus(t) | |||
| 106 | h.peers.exe.Store("/usr/bin/grim") | 131 | h.peers.exe.Store("/usr/bin/grim") | ||
| 107 | if o.holdOp { | 132 | if o.holdOp { | ||
| 108 | h.peers.openGo = make(chan struct{}) | 133 | h.peers.openGo = make(chan struct{}) | ||
| @@ -123,14 +148,23 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |||||
| 123 | return nil, h.peers.openErr | 148 | return nil, h.peers.openErr | ||
| 124 | } | 149 | } | ||
| 125 | h.peers.pid.Store(int64(pid)) | 150 | h.peers.pid.Store(int64(pid)) | ||
| 126 | - | return os.Open(os.DevNull) | 151 | + | f, err := os.Open(os.DevNull) |
| 152 | + | if err == nil { | |||
| 153 | + | h.peers.mu.Lock() | |||
| 154 | + | h.peers.opened = append(h.peers.opened, f) | |||
| 155 | + | h.peers.mu.Unlock() | |||
| 156 | + | } | |||
| 157 | + | return f, err | |||
| 127 | }).Maybe() | 158 | }).Maybe() | ||
| 128 | - | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) { | 159 | + | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(f *os.File) (string, int, error) { |
| 129 | open := int(h.peers.pid.Load()) | 160 | open := int(h.peers.pid.Load()) | ||
| 130 | pid := open | 161 | pid := open | ||
| 131 | if h.peers.exePid != nil { | 162 | if h.peers.exePid != nil { | ||
| 132 | pid = h.peers.exePid(open) | 163 | pid = h.peers.exePid(open) | ||
| 133 | } | 164 | } | ||
| 165 | + | if h.peers.exeFile != nil { | |||
| 166 | + | return h.peers.exeFile(h.peers.number(f)), pid, nil | |||
| 167 | + | } | |||
| 134 | if h.peers.exeNow != nil { | 168 | if h.peers.exeNow != nil { | ||
| 135 | return h.peers.exeNow(), pid, nil | 169 | return h.peers.exeNow(), pid, nil | ||
| 136 | } | 170 | } | ||
| @@ -151,8 +185,17 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |||||
| 151 | t.Cleanup(func() { r.Close(); w.Close() }) | 185 | t.Cleanup(func() { r.Close(); w.Close() }) | ||
| 152 | h.core = newMockvideoCore(t) | 186 | h.core = newMockvideoCore(t) | ||
| 153 | h.core.EXPECT().Fd().Return(int(r.Fd())) | 187 | h.core.EXPECT().Fd().Return(int(r.Fd())) | ||
| 154 | - | h.core.EXPECT().Iterate().Maybe() | 188 | + | h.core.EXPECT().Iterate().Run(func() { |
| 155 | - | h.core.EXPECT().Close().Return().Maybe() | 189 | + | if o.iterate != nil { |
| 190 | + | o.iterate() | |||
| 191 | + | } | |||
| 192 | + | }).Maybe() | |||
| 193 | + | h.core.EXPECT().Close().Run(func() { | |||
| 194 | + | select { | |||
| 195 | + | case h.closed <- struct{}{}: | |||
| 196 | + | default: | |||
| 197 | + | } | |||
| 198 | + | }).Maybe() | |||
| 156 | pwOpen.EXPECT().Open().Return(h.core, nil) | 199 | pwOpen.EXPECT().Open().Return(h.core, nil) | ||
| 157 | } | 200 | } | ||
| 158 | h.svc.pw = pwOpen | 201 | h.svc.pw = pwOpen | ||
M internal/adapters/screencast/producer.go +39 -14
| @@ -19,6 +19,15 @@ import ( | |||
| 19 | 19 | // The buffer's mapping is lent to the wayland server until the CastFrameDone | |
| 20 | 20 | // of that buffer, so the source is never resized or destroyed while a frame | |
| 21 | 21 | // is in flight. | |
| 22 | + | // | |
| 23 | + | // A cast ends (the session signals Closed) on Stop, when the owner leaves | |
| 24 | + | // the bus (NameOwnerChanged), when wayland stops the cast (output or window | |
| 25 | + | // gone, lock), when the PipeWire stream goes to ERROR or back to | |
| 26 | + | // UNCONNECTED, and when the service shuts down. It does not end when the | |
| 27 | + | // consumer leaves: as in Mutter, the node then goes to PAUSED, no frame is | |
| 28 | + | // requested while it is not STREAMING (a frame already in flight is | |
| 29 | + | // released instead of queued), and the first frame after it is STREAMING | |
| 30 | + | // again is forced. The D-Bus session stays up for the next consumer. | |
| 22 | 31 | ||
| 23 | 32 | // wl_shm formats of the supported video formats. | |
| 24 | 33 | const ( | |
| @@ -104,10 +113,19 @@ func (s *Service) giveBack(st *stream) { | |||
| 104 | 113 | // frameDone settles the frame in flight: a complete image is queued and | |
| 105 | 114 | // triggered, anything else gives the buffer back. | |
| 106 | 115 | func (s *Service) frameDone(st *stream, ev *ports.CastEvent) { | |
| 107 | - | if b := s.settleFrame(st, ev); b != nil { | |
| 108 | - | st.src.Queue(b, st.seq, st.pts, false) | |
| 109 | - | st.src.Trigger() | |
| 116 | + | b := s.settleFrame(st, ev) | |
| 117 | + | if b == nil { | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | if st.pwState != pipewire.StreamStateStreaming { | |
| 121 | + | // The consumer left while the frame was drawn: nobody wants it. | |
| 122 | + | // The picture is kept for the consumer that comes next. | |
| 123 | + | st.src.Release(b) | |
| 124 | + | st.dirty = true | |
| 125 | + | return | |
| 110 | 126 | } | |
| 127 | + | st.src.Queue(b, st.seq, st.pts, false) | |
| 128 | + | st.src.Trigger() | |
| 111 | 129 | } | |
| 112 | 130 | ||
| 113 | 131 | // settleFrame updates the stream for the CastFrameDone of its frame and | |
| @@ -299,14 +317,16 @@ func (s *Service) issueFrame(st *stream, b *pipewire.VideoBuffer, now time.Time) | |||
| 299 | 317 | } | |
| 300 | 318 | ||
| 301 | 319 | // pumpClosing finishes a stream of an ended session: CastClose, then the | |
| 302 | - | // PipeWire node once no frame is in flight. | |
| 320 | + | // PipeWire node once no frame is in flight. Destroying the node does not | |
| 321 | + | // wait for CastClose to be sent (only a frame in flight lends its buffer), | |
| 322 | + | // but the slot is kept until it is. | |
| 303 | 323 | func (s *Service) pumpClosing(st *stream) { | |
| 304 | 324 | if st.needClose { | |
| 305 | - | if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { | |
| 325 | + | if s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { | |
| 326 | + | st.needClose = false | |
| 327 | + | } else { | |
| 306 | 328 | s.wait(retryOpen) | |
| 307 | - | return | |
| 308 | 329 | } | |
| 309 | - | st.needClose = false | |
| 310 | 330 | } | |
| 311 | 331 | if st.inflight { | |
| 312 | 332 | return | |
| @@ -316,7 +336,9 @@ func (s *Service) pumpClosing(st *stream) { | |||
| 316 | 336 | st.src.Destroy() | |
| 317 | 337 | st.src = nil | |
| 318 | 338 | } | |
| 319 | - | *st = stream{} | |
| 339 | + | if !st.needClose { | |
| 340 | + | *st = stream{} | |
| 341 | + | } | |
| 320 | 342 | } | |
| 321 | 343 | ||
| 322 | 344 | // reap frees the dying sessions once every stream released its resources. | |
| @@ -336,10 +358,13 @@ func (s *Service) reap() { | |||
| 336 | 358 | } | |
| 337 | 359 | } | |
| 338 | 360 | ||
| 339 | - | // streamsLive reports whether a stream still holds PipeWire resources or a | |
| 340 | - | // lent buffer. | |
| 341 | - | func (s *Service) streamsLive() bool { | |
| 342 | - | live := false | |
| 343 | - | s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight }) | |
| 344 | - | return live | |
| 361 | + | // streamCount is the number of streams that still hold something: a | |
| 362 | + | // PipeWire source, a lent buffer or an unsent CastClose. | |
| 363 | + | func (s *Service) streamCount() int { | |
| 364 | + | n := 0 | |
| 365 | + | s.forEachStream(func(*stream) { n++ }) | |
| 366 | + | return n | |
| 345 | 367 | } | |
| 368 | + | ||
| 369 | + | // streamsLive reports whether a stream still holds something to release. | |
| 370 | + | func (s *Service) streamsLive() bool { return s.streamCount() > 0 } | |
| @@ -19,6 +19,15 @@ import ( | |||||
| 19 | // The buffer's mapping is lent to the wayland server until the CastFrameDone | 19 | // The buffer's mapping is lent to the wayland server until the CastFrameDone | ||
| 20 | // of that buffer, so the source is never resized or destroyed while a frame | 20 | // of that buffer, so the source is never resized or destroyed while a frame | ||
| 21 | // is in flight. | 21 | // is in flight. | ||
| 22 | + | // | |||
| 23 | + | // A cast ends (the session signals Closed) on Stop, when the owner leaves | |||
| 24 | + | // the bus (NameOwnerChanged), when wayland stops the cast (output or window | |||
| 25 | + | // gone, lock), when the PipeWire stream goes to ERROR or back to | |||
| 26 | + | // UNCONNECTED, and when the service shuts down. It does not end when the | |||
| 27 | + | // consumer leaves: as in Mutter, the node then goes to PAUSED, no frame is | |||
| 28 | + | // requested while it is not STREAMING (a frame already in flight is | |||
| 29 | + | // released instead of queued), and the first frame after it is STREAMING | |||
| 30 | + | // again is forced. The D-Bus session stays up for the next consumer. | |||
| 22 | 31 | ||||
| 23 | // wl_shm formats of the supported video formats. | 32 | // wl_shm formats of the supported video formats. | ||
| 24 | const ( | 33 | const ( | ||
| @@ -104,10 +113,19 @@ func (s *Service) giveBack(st *stream) { | |||||
| 104 | // frameDone settles the frame in flight: a complete image is queued and | 113 | // frameDone settles the frame in flight: a complete image is queued and | ||
| 105 | // triggered, anything else gives the buffer back. | 114 | // triggered, anything else gives the buffer back. | ||
| 106 | func (s *Service) frameDone(st *stream, ev *ports.CastEvent) { | 115 | func (s *Service) frameDone(st *stream, ev *ports.CastEvent) { | ||
| 107 | - | if b := s.settleFrame(st, ev); b != nil { | 116 | + | b := s.settleFrame(st, ev) |
| 108 | - | st.src.Queue(b, st.seq, st.pts, false) | 117 | + | if b == nil { |
| 109 | - | st.src.Trigger() | 118 | + | return |
| 119 | + | } | |||
| 120 | + | if st.pwState != pipewire.StreamStateStreaming { | |||
| 121 | + | // The consumer left while the frame was drawn: nobody wants it. | |||
| 122 | + | // The picture is kept for the consumer that comes next. | |||
| 123 | + | st.src.Release(b) | |||
| 124 | + | st.dirty = true | |||
| 125 | + | return | |||
| 110 | } | 126 | } | ||
| 127 | + | st.src.Queue(b, st.seq, st.pts, false) | |||
| 128 | + | st.src.Trigger() | |||
| 111 | } | 129 | } | ||
| 112 | 130 | ||||
| 113 | // settleFrame updates the stream for the CastFrameDone of its frame and | 131 | // settleFrame updates the stream for the CastFrameDone of its frame and | ||
| @@ -299,14 +317,16 @@ func (s *Service) issueFrame(st *stream, b *pipewire.VideoBuffer, now time.Time) | |||||
| 299 | } | 317 | } | ||
| 300 | 318 | ||||
| 301 | // pumpClosing finishes a stream of an ended session: CastClose, then the | 319 | // pumpClosing finishes a stream of an ended session: CastClose, then the | ||
| 302 | - | // PipeWire node once no frame is in flight. | 320 | + | // PipeWire node once no frame is in flight. Destroying the node does not |
| 321 | + | // wait for CastClose to be sent (only a frame in flight lends its buffer), | |||
| 322 | + | // but the slot is kept until it is. | |||
| 303 | func (s *Service) pumpClosing(st *stream) { | 323 | func (s *Service) pumpClosing(st *stream) { | ||
| 304 | if st.needClose { | 324 | if st.needClose { | ||
| 305 | - | if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { | 325 | + | if s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { |
| 326 | + | st.needClose = false | |||
| 327 | + | } else { | |||
| 306 | s.wait(retryOpen) | 328 | s.wait(retryOpen) | ||
| 307 | - | return | |||
| 308 | } | 329 | } | ||
| 309 | - | st.needClose = false | |||
| 310 | } | 330 | } | ||
| 311 | if st.inflight { | 331 | if st.inflight { | ||
| 312 | return | 332 | return | ||
| @@ -316,7 +336,9 @@ func (s *Service) pumpClosing(st *stream) { | |||||
| 316 | st.src.Destroy() | 336 | st.src.Destroy() | ||
| 317 | st.src = nil | 337 | st.src = nil | ||
| 318 | } | 338 | } | ||
| 319 | - | *st = stream{} | 339 | + | if !st.needClose { |
| 340 | + | *st = stream{} | |||
| 341 | + | } | |||
| 320 | } | 342 | } | ||
| 321 | 343 | ||||
| 322 | // reap frees the dying sessions once every stream released its resources. | 344 | // reap frees the dying sessions once every stream released its resources. | ||
| @@ -336,10 +358,13 @@ func (s *Service) reap() { | |||||
| 336 | } | 358 | } | ||
| 337 | } | 359 | } | ||
| 338 | 360 | ||||
| 339 | - | // streamsLive reports whether a stream still holds PipeWire resources or a | 361 | + | // streamCount is the number of streams that still hold something: a |
| 340 | - | // lent buffer. | 362 | + | // PipeWire source, a lent buffer or an unsent CastClose. |
| 341 | - | func (s *Service) streamsLive() bool { | 363 | + | func (s *Service) streamCount() int { |
| 342 | - | live := false | 364 | + | n := 0 |
| 343 | - | s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight }) | 365 | + | s.forEachStream(func(*stream) { n++ }) |
| 344 | - | return live | 366 | + | return n |
| 345 | } | 367 | } | ||
| 368 | + | ||||
| 369 | + | // streamsLive reports whether a stream still holds something to release. | |||
| 370 | + | func (s *Service) streamsLive() bool { return s.streamCount() > 0 } | |||
M internal/adapters/screencast/producer_test.go +140 -0
| @@ -322,3 +322,143 @@ func TestCastProducerAllocations(t *testing.T) { | |||
| 322 | 322 | t.Fatalf("%v allocations per frame, want 0", n) | |
| 323 | 323 | } | |
| 324 | 324 | } | |
| 325 | + | ||
| 326 | + | // The consumer leaves: the node goes to PAUSED and no frame is requested, | |
| 327 | + | // whatever damage and time bring, while the session stays open. A frame | |
| 328 | + | // already in flight is released, not queued. When the node streams again the | |
| 329 | + | // first frame is forced. | |
| 330 | + | func TestPausedNodeGetsNoFramesAndSessionStays(t *testing.T) { | |
| 331 | + | p := newProducer(t) | |
| 332 | + | p.streaming() | |
| 333 | + | p.src.EXPECT().Dequeue().Return(p.buf).Twice() | |
| 334 | + | p.s.pump(p.st, p.now) | |
| 335 | + | if c := p.cmd(); c.Kind != ports.CastFrame { | |
| 336 | + | t.Fatalf("%+v", c) | |
| 337 | + | } | |
| 338 | + | // Paused with the frame in flight: it is given back, not queued (no | |
| 339 | + | // Queue or Trigger expectation). | |
| 340 | + | p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "") | |
| 341 | + | p.src.EXPECT().Release(p.buf).Return().Once() | |
| 342 | + | p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Time: time.Unix(5, 0)}) | |
| 343 | + | for range 5 { | |
| 344 | + | p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastDamage, ID: 1}) | |
| 345 | + | p.now = p.now.Add(time.Second) | |
| 346 | + | p.s.pumpAll(p.now) // Dequeue is not expected a second time while paused | |
| 347 | + | p.none() | |
| 348 | + | } | |
| 349 | + | if p.s.sessions[0].dying || p.st.closing { | |
| 350 | + | t.Fatal("the session ended with the consumer") | |
| 351 | + | } | |
| 352 | + | if got := p.s.timeout(); got != -1 { | |
| 353 | + | t.Fatalf("paused stream asks for a timeout of %d ms", got) | |
| 354 | + | } | |
| 355 | + | // A consumer is back: the picture goes out at once, damage or not. | |
| 356 | + | p.st.h2.StateChanged(pipewire.StreamStateStreaming, 42, "") | |
| 357 | + | p.s.pumpAll(p.now) | |
| 358 | + | if c := p.cmd(); c.Kind != ports.CastFrame { | |
| 359 | + | t.Fatalf("no frame after the node streams again: %+v", c) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Shutdown pumps every stream: CastClose goes out, the node is destroyed and | |
| 364 | + | // PipeWire closed, with no frame in flight. | |
| 365 | + | func TestShutdownClosesStreams(t *testing.T) { | |
| 366 | + | p := newProducer(t) | |
| 367 | + | p.streaming() | |
| 368 | + | var order []string | |
| 369 | + | p.src.EXPECT().Destroy().Run(func() { order = append(order, "destroy") }).Once() | |
| 370 | + | p.core.EXPECT().Close().Run(func() { order = append(order, "close") }).Once() | |
| 371 | + | p.s.shutdown() | |
| 372 | + | if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 { | |
| 373 | + | t.Fatalf("%+v", c) | |
| 374 | + | } | |
| 375 | + | if len(order) != 2 || order[0] != "destroy" || order[1] != "close" || p.s.pwc != nil { | |
| 376 | + | t.Fatalf("order %v", order) | |
| 377 | + | } | |
| 378 | + | if p.st.used || p.s.sessions[0].used { | |
| 379 | + | t.Fatal("slots not freed") | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // A stream opened on the wayland side that has no node yet is closed too. | |
| 384 | + | func TestShutdownClosesStreamWithoutSource(t *testing.T) { | |
| 385 | + | p := newProducer(t) | |
| 386 | + | p.s.pump(p.st, p.now) | |
| 387 | + | if c := p.cmd(); c.Kind != ports.CastOpen { | |
| 388 | + | t.Fatalf("%+v", c) | |
| 389 | + | } | |
| 390 | + | p.core.EXPECT().Close().Return().Once() | |
| 391 | + | p.s.shutdown() | |
| 392 | + | if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 { | |
| 393 | + | t.Fatalf("%+v", c) | |
| 394 | + | } | |
| 395 | + | if p.st.used { | |
| 396 | + | t.Fatal("stream not freed") | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // A frame in flight at shutdown is waited for: nothing is destroyed before | |
| 401 | + | // its CastFrameDone, then the buffer is released and the node destroyed. | |
| 402 | + | func TestShutdownWaitsForFrameInFlight(t *testing.T) { | |
| 403 | + | p := newProducer(t) | |
| 404 | + | p.streaming() | |
| 405 | + | p.src.EXPECT().Dequeue().Return(p.buf).Once() | |
| 406 | + | p.s.pump(p.st, p.now) | |
| 407 | + | p.cmd() | |
| 408 | + | var order []string | |
| 409 | + | p.src.EXPECT().Release(p.buf).Run(func(*pipewire.VideoBuffer) { order = append(order, "release") }).Once() | |
| 410 | + | p.src.EXPECT().Destroy().Run(func() { order = append(order, "destroy") }).Once() | |
| 411 | + | p.core.EXPECT().Close().Run(func() { order = append(order, "close") }).Once() | |
| 412 | + | go func() { | |
| 413 | + | time.Sleep(30 * time.Millisecond) | |
| 414 | + | p.s.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3}) | |
| 415 | + | }() | |
| 416 | + | start := time.Now() | |
| 417 | + | p.s.shutdown() | |
| 418 | + | if time.Since(start) < 30*time.Millisecond { | |
| 419 | + | t.Fatal("shutdown did not wait for the frame") | |
| 420 | + | } | |
| 421 | + | if c := p.cmd(); c.Kind != ports.CastClose { | |
| 422 | + | t.Fatalf("%+v", c) | |
| 423 | + | } | |
| 424 | + | if len(order) != 3 || order[0] != "release" || order[1] != "destroy" || order[2] != "close" { | |
| 425 | + | t.Fatalf("order %v", order) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | // When the frame never comes back, nothing is destroyed or closed (the | |
| 430 | + | // source's handler and its buffer are still in use), the storage the | |
| 431 | + | // handler points into is left untouched and abandoned, and the service has | |
| 432 | + | // a fresh table: repeated reconnects do not exhaust it. | |
| 433 | + | func TestShutdownTimeoutKeepsHandlerStateAndFreesTable(t *testing.T) { | |
| 434 | + | p := newProducer(t) | |
| 435 | + | p.s.drain = 20 * time.Millisecond | |
| 436 | + | p.streaming() | |
| 437 | + | p.src.EXPECT().Dequeue().Return(p.buf).Once() | |
| 438 | + | p.s.pump(p.st, p.now) | |
| 439 | + | p.cmd() | |
| 440 | + | old := p.s.sessions | |
| 441 | + | p.s.shutdown() // no Destroy, Release or Close is expected | |
| 442 | + | if c := p.cmd(); c.Kind != ports.CastClose { | |
| 443 | + | t.Fatalf("%+v", c) | |
| 444 | + | } | |
| 445 | + | if p.s.sessions == old { | |
| 446 | + | t.Fatal("the table the leaked handler points into is still in use") | |
| 447 | + | } | |
| 448 | + | if !p.st.used || p.st.src == nil || !p.st.inflight || p.st.h2.st != p.st { | |
| 449 | + | t.Fatal("the leaked stream's state was reset") | |
| 450 | + | } | |
| 451 | + | if p.s.streamsLive() || p.s.pwc != nil { | |
| 452 | + | t.Fatal("new table not empty or PipeWire kept") | |
| 453 | + | } | |
| 454 | + | // The handler of the leaked source still works on its own storage. | |
| 455 | + | p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "") | |
| 456 | + | if p.st.pwState != pipewire.StreamStatePaused { | |
| 457 | + | t.Fatal("handler lost its stream") | |
| 458 | + | } | |
| 459 | + | // A new attempt starts clean and can fill its table again. | |
| 460 | + | p.s.reset(p.s.ctx, p.bus) | |
| 461 | + | for i := range maxSessions { | |
| 462 | + | p.s.sessions[i] = session{used: true, id: uint32(i + 1)} | |
| 463 | + | } | |
| 464 | + | } | |
| @@ -322,3 +322,143 @@ func TestCastProducerAllocations(t *testing.T) { | |||||
| 322 | t.Fatalf("%v allocations per frame, want 0", n) | 322 | t.Fatalf("%v allocations per frame, want 0", n) | ||
| 323 | } | 323 | } | ||
| 324 | } | 324 | } | ||
| 325 | + | ||||
| 326 | + | // The consumer leaves: the node goes to PAUSED and no frame is requested, | |||
| 327 | + | // whatever damage and time bring, while the session stays open. A frame | |||
| 328 | + | // already in flight is released, not queued. When the node streams again the | |||
| 329 | + | // first frame is forced. | |||
| 330 | + | func TestPausedNodeGetsNoFramesAndSessionStays(t *testing.T) { | |||
| 331 | + | p := newProducer(t) | |||
| 332 | + | p.streaming() | |||
| 333 | + | p.src.EXPECT().Dequeue().Return(p.buf).Twice() | |||
| 334 | + | p.s.pump(p.st, p.now) | |||
| 335 | + | if c := p.cmd(); c.Kind != ports.CastFrame { | |||
| 336 | + | t.Fatalf("%+v", c) | |||
| 337 | + | } | |||
| 338 | + | // Paused with the frame in flight: it is given back, not queued (no | |||
| 339 | + | // Queue or Trigger expectation). | |||
| 340 | + | p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "") | |||
| 341 | + | p.src.EXPECT().Release(p.buf).Return().Once() | |||
| 342 | + | p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Time: time.Unix(5, 0)}) | |||
| 343 | + | for range 5 { | |||
| 344 | + | p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastDamage, ID: 1}) | |||
| 345 | + | p.now = p.now.Add(time.Second) | |||
| 346 | + | p.s.pumpAll(p.now) // Dequeue is not expected a second time while paused | |||
| 347 | + | p.none() | |||
| 348 | + | } | |||
| 349 | + | if p.s.sessions[0].dying || p.st.closing { | |||
| 350 | + | t.Fatal("the session ended with the consumer") | |||
| 351 | + | } | |||
| 352 | + | if got := p.s.timeout(); got != -1 { | |||
| 353 | + | t.Fatalf("paused stream asks for a timeout of %d ms", got) | |||
| 354 | + | } | |||
| 355 | + | // A consumer is back: the picture goes out at once, damage or not. | |||
| 356 | + | p.st.h2.StateChanged(pipewire.StreamStateStreaming, 42, "") | |||
| 357 | + | p.s.pumpAll(p.now) | |||
| 358 | + | if c := p.cmd(); c.Kind != ports.CastFrame { | |||
| 359 | + | t.Fatalf("no frame after the node streams again: %+v", c) | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // Shutdown pumps every stream: CastClose goes out, the node is destroyed and | |||
| 364 | + | // PipeWire closed, with no frame in flight. | |||
| 365 | + | func TestShutdownClosesStreams(t *testing.T) { | |||
| 366 | + | p := newProducer(t) | |||
| 367 | + | p.streaming() | |||
| 368 | + | var order []string | |||
| 369 | + | p.src.EXPECT().Destroy().Run(func() { order = append(order, "destroy") }).Once() | |||
| 370 | + | p.core.EXPECT().Close().Run(func() { order = append(order, "close") }).Once() | |||
| 371 | + | p.s.shutdown() | |||
| 372 | + | if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 { | |||
| 373 | + | t.Fatalf("%+v", c) | |||
| 374 | + | } | |||
| 375 | + | if len(order) != 2 || order[0] != "destroy" || order[1] != "close" || p.s.pwc != nil { | |||
| 376 | + | t.Fatalf("order %v", order) | |||
| 377 | + | } | |||
| 378 | + | if p.st.used || p.s.sessions[0].used { | |||
| 379 | + | t.Fatal("slots not freed") | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | // A stream opened on the wayland side that has no node yet is closed too. | |||
| 384 | + | func TestShutdownClosesStreamWithoutSource(t *testing.T) { | |||
| 385 | + | p := newProducer(t) | |||
| 386 | + | p.s.pump(p.st, p.now) | |||
| 387 | + | if c := p.cmd(); c.Kind != ports.CastOpen { | |||
| 388 | + | t.Fatalf("%+v", c) | |||
| 389 | + | } | |||
| 390 | + | p.core.EXPECT().Close().Return().Once() | |||
| 391 | + | p.s.shutdown() | |||
| 392 | + | if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 { | |||
| 393 | + | t.Fatalf("%+v", c) | |||
| 394 | + | } | |||
| 395 | + | if p.st.used { | |||
| 396 | + | t.Fatal("stream not freed") | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // A frame in flight at shutdown is waited for: nothing is destroyed before | |||
| 401 | + | // its CastFrameDone, then the buffer is released and the node destroyed. | |||
| 402 | + | func TestShutdownWaitsForFrameInFlight(t *testing.T) { | |||
| 403 | + | p := newProducer(t) | |||
| 404 | + | p.streaming() | |||
| 405 | + | p.src.EXPECT().Dequeue().Return(p.buf).Once() | |||
| 406 | + | p.s.pump(p.st, p.now) | |||
| 407 | + | p.cmd() | |||
| 408 | + | var order []string | |||
| 409 | + | p.src.EXPECT().Release(p.buf).Run(func(*pipewire.VideoBuffer) { order = append(order, "release") }).Once() | |||
| 410 | + | p.src.EXPECT().Destroy().Run(func() { order = append(order, "destroy") }).Once() | |||
| 411 | + | p.core.EXPECT().Close().Run(func() { order = append(order, "close") }).Once() | |||
| 412 | + | go func() { | |||
| 413 | + | time.Sleep(30 * time.Millisecond) | |||
| 414 | + | p.s.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3}) | |||
| 415 | + | }() | |||
| 416 | + | start := time.Now() | |||
| 417 | + | p.s.shutdown() | |||
| 418 | + | if time.Since(start) < 30*time.Millisecond { | |||
| 419 | + | t.Fatal("shutdown did not wait for the frame") | |||
| 420 | + | } | |||
| 421 | + | if c := p.cmd(); c.Kind != ports.CastClose { | |||
| 422 | + | t.Fatalf("%+v", c) | |||
| 423 | + | } | |||
| 424 | + | if len(order) != 3 || order[0] != "release" || order[1] != "destroy" || order[2] != "close" { | |||
| 425 | + | t.Fatalf("order %v", order) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // When the frame never comes back, nothing is destroyed or closed (the | |||
| 430 | + | // source's handler and its buffer are still in use), the storage the | |||
| 431 | + | // handler points into is left untouched and abandoned, and the service has | |||
| 432 | + | // a fresh table: repeated reconnects do not exhaust it. | |||
| 433 | + | func TestShutdownTimeoutKeepsHandlerStateAndFreesTable(t *testing.T) { | |||
| 434 | + | p := newProducer(t) | |||
| 435 | + | p.s.drain = 20 * time.Millisecond | |||
| 436 | + | p.streaming() | |||
| 437 | + | p.src.EXPECT().Dequeue().Return(p.buf).Once() | |||
| 438 | + | p.s.pump(p.st, p.now) | |||
| 439 | + | p.cmd() | |||
| 440 | + | old := p.s.sessions | |||
| 441 | + | p.s.shutdown() // no Destroy, Release or Close is expected | |||
| 442 | + | if c := p.cmd(); c.Kind != ports.CastClose { | |||
| 443 | + | t.Fatalf("%+v", c) | |||
| 444 | + | } | |||
| 445 | + | if p.s.sessions == old { | |||
| 446 | + | t.Fatal("the table the leaked handler points into is still in use") | |||
| 447 | + | } | |||
| 448 | + | if !p.st.used || p.st.src == nil || !p.st.inflight || p.st.h2.st != p.st { | |||
| 449 | + | t.Fatal("the leaked stream's state was reset") | |||
| 450 | + | } | |||
| 451 | + | if p.s.streamsLive() || p.s.pwc != nil { | |||
| 452 | + | t.Fatal("new table not empty or PipeWire kept") | |||
| 453 | + | } | |||
| 454 | + | // The handler of the leaked source still works on its own storage. | |||
| 455 | + | p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "") | |||
| 456 | + | if p.st.pwState != pipewire.StreamStatePaused { | |||
| 457 | + | t.Fatal("handler lost its stream") | |||
| 458 | + | } | |||
| 459 | + | // A new attempt starts clean and can fill its table again. | |||
| 460 | + | p.s.reset(p.s.ctx, p.bus) | |||
| 461 | + | for i := range maxSessions { | |||
| 462 | + | p.s.sessions[i] = session{used: true, id: uint32(i + 1)} | |||
| 463 | + | } | |||
| 464 | + | } | |||
M internal/adapters/screencast/screencast.go +49 -20
| @@ -54,6 +54,10 @@ const ( | |||
| 54 | 54 | ||
| 55 | 55 | // shutdownWait bounds the wait for frames still in flight at shutdown. | |
| 56 | 56 | shutdownWait = 2 * time.Second | |
| 57 | + | ||
| 58 | + | // shutdownPoll is how often shutdown looks again for the events that | |
| 59 | + | // settle a frame in flight. | |
| 60 | + | shutdownPoll = 5 * time.Millisecond | |
| 57 | 61 | ) | |
| 58 | 62 | ||
| 59 | 63 | var _ ports.CastSink = (*Service)(nil) | |
| @@ -87,22 +91,27 @@ type Service struct { | |||
| 87 | 91 | shotRes shotResult | |
| 88 | 92 | ||
| 89 | 93 | // Owner state. | |
| 90 | - | ctx context.Context | |
| 91 | - | conn bus | |
| 92 | - | pwc videoCore | |
| 93 | - | owns [4]bool // nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot | |
| 94 | - | inv ports.CastInventory | |
| 95 | - | haveInv bool | |
| 96 | - | callers [maxCallers]caller | |
| 97 | - | pending [maxPending]pending | |
| 98 | - | sessions [maxSessions]session | |
| 94 | + | ctx context.Context // the attempt's: it ends with the attempt | |
| 95 | + | conn bus | |
| 96 | + | pwc videoCore | |
| 97 | + | owns [4]bool // nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot | |
| 98 | + | inv ports.CastInventory | |
| 99 | + | haveInv bool | |
| 100 | + | callers [maxCallers]caller | |
| 101 | + | pending [maxPending]pending | |
| 102 | + | // sessions is a pointer so that a shutdown that had to leave a PipeWire | |
| 103 | + | // source behind can abandon the table: the source's handler still | |
| 104 | + | // points into it, so it is never reset or reused. | |
| 105 | + | sessions *[maxSessions]session | |
| 99 | 106 | nextSess uint32 | |
| 100 | 107 | nextStrm uint32 | |
| 101 | 108 | nextCast uint64 | |
| 102 | 109 | waitFor time.Duration // time to the next paced frame, 0 when none | |
| 103 | 110 | shot shotState | |
| 111 | + | shotSeq uint64 // ID of the last screenshot job; never reset | |
| 104 | 112 | fatal error | |
| 105 | 113 | jobs sync.WaitGroup | |
| 114 | + | drain time.Duration // how long shutdown waits for frames in flight | |
| 106 | 115 | } | |
| 107 | 116 | ||
| 108 | 117 | // New creates the service. policy returns the capture allowlist in force; | |
| @@ -118,6 +127,9 @@ func New(policy func() *captureallow.Policy, cmds chan<- ports.CastCommand, log | |||
| 118 | 127 | peers: pidfdPeers{}, | |
| 119 | 128 | queue: make([]ports.CastEvent, 0, sinkCap), | |
| 120 | 129 | spare: make([]ports.CastEvent, 0, sinkCap), | |
| 130 | + | ||
| 131 | + | sessions: new([maxSessions]session), | |
| 132 | + | drain: shutdownWait, | |
| 121 | 133 | } | |
| 122 | 134 | s.poller, s.initErr = fdwait.New() | |
| 123 | 135 | return s | |
| @@ -204,6 +216,9 @@ func (s *Service) attempt(ctx context.Context) (err error) { | |||
| 204 | 216 | return err | |
| 205 | 217 | } | |
| 206 | 218 | defer func() { _ = conn.Close() }() | |
| 219 | + | // The screenshot job of this attempt ends with it. | |
| 220 | + | ctx, cancel := context.WithCancel(ctx) | |
| 221 | + | defer cancel() | |
| 207 | 222 | s.reset(ctx, conn) | |
| 208 | 223 | if s.pwc, err = s.pw.Open(); err != nil { | |
| 209 | 224 | s.pwc = nil | |
| @@ -223,7 +238,7 @@ func (s *Service) reset(ctx context.Context, conn bus) { | |||
| 223 | 238 | s.haveInv = false | |
| 224 | 239 | s.callers = [maxCallers]caller{} | |
| 225 | 240 | s.pending = [maxPending]pending{} | |
| 226 | - | s.sessions = [maxSessions]session{} | |
| 241 | + | *s.sessions = [maxSessions]session{} | |
| 227 | 242 | s.shot = shotState{} | |
| 228 | 243 | } | |
| 229 | 244 | ||
| @@ -326,24 +341,38 @@ func (s *Service) service() { | |||
| 326 | 341 | s.reap() | |
| 327 | 342 | } | |
| 328 | 343 | ||
| 329 | - | // shutdown ends every session and releases PipeWire. A frame still in | |
| 330 | - | // flight is waited for: its buffer is lent to the wayland server. | |
| 344 | + | // shutdown ends the attempt: the screenshot job is stopped and joined, every | |
| 345 | + | // session is closed, and every stream is pumped until it released its | |
| 346 | + | // resources: CastClose goes to the wayland server, a frame still in flight | |
| 347 | + | // is waited for (its buffer is lent to the wayland server), then the source | |
| 348 | + | // is destroyed and its buffers released. Only then is PipeWire closed. | |
| 349 | + | // | |
| 350 | + | // When a frame does not come back in time, the source cannot be destroyed | |
| 351 | + | // safely. It is left as it is, with its loop: the sessions table that its | |
| 352 | + | // handler points into is abandoned (never reset or reused) and a new one | |
| 353 | + | // takes its place, so later attempts neither touch that storage nor run out | |
| 354 | + | // of slots. | |
| 331 | 355 | func (s *Service) shutdown() { | |
| 356 | + | s.stopShot() | |
| 332 | 357 | s.forEachSession(func(sess *session) { s.closeSession(sess, true) }) | |
| 333 | - | deadline := time.Now().Add(shutdownWait) | |
| 334 | - | for s.streamsLive() && time.Now().Before(deadline) { | |
| 358 | + | deadline := time.Now().Add(s.drain) | |
| 359 | + | for { | |
| 335 | 360 | evs, _, _, _ := s.take() | |
| 336 | 361 | for i := range evs { | |
| 337 | 362 | s.onCastEvent(&evs[i]) | |
| 338 | 363 | } | |
| 364 | + | s.pumpAll(s.clock.Now()) | |
| 339 | 365 | s.reap() | |
| 340 | - | if s.streamsLive() { | |
| 341 | - | time.Sleep(5 * time.Millisecond) | |
| 366 | + | if !s.streamsLive() { | |
| 367 | + | break | |
| 342 | 368 | } | |
| 343 | - | } | |
| 344 | - | if s.streamsLive() { | |
| 345 | - | s.log.Warn().Msg("frames still in flight at shutdown; PipeWire resources are left to the process exit") | |
| 346 | - | s.pwc = nil | |
| 369 | + | if !time.Now().Before(deadline) { | |
| 370 | + | s.log.Warn().Int("streams", s.streamCount()).Msg("frames still in flight at shutdown; PipeWire resources are left to the process exit") | |
| 371 | + | s.sessions = new([maxSessions]session) | |
| 372 | + | s.pwc = nil | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | time.Sleep(shutdownPoll) | |
| 347 | 376 | } | |
| 348 | 377 | if s.pwc != nil { | |
| 349 | 378 | s.pwc.Close() | |
| @@ -54,6 +54,10 @@ const ( | |||||
| 54 | 54 | ||||
| 55 | // shutdownWait bounds the wait for frames still in flight at shutdown. | 55 | // shutdownWait bounds the wait for frames still in flight at shutdown. | ||
| 56 | shutdownWait = 2 * time.Second | 56 | shutdownWait = 2 * time.Second | ||
| 57 | + | ||||
| 58 | + | // shutdownPoll is how often shutdown looks again for the events that | |||
| 59 | + | // settle a frame in flight. | |||
| 60 | + | shutdownPoll = 5 * time.Millisecond | |||
| 57 | ) | 61 | ) | ||
| 58 | 62 | ||||
| 59 | var _ ports.CastSink = (*Service)(nil) | 63 | var _ ports.CastSink = (*Service)(nil) | ||
| @@ -87,22 +91,27 @@ type Service struct { | |||||
| 87 | shotRes shotResult | 91 | shotRes shotResult | ||
| 88 | 92 | ||||
| 89 | // Owner state. | 93 | // Owner state. | ||
| 90 | - | ctx context.Context | 94 | + | ctx context.Context // the attempt's: it ends with the attempt |
| 91 | - | conn bus | 95 | + | conn bus |
| 92 | - | pwc videoCore | 96 | + | pwc videoCore |
| 93 | - | owns [4]bool // nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot | 97 | + | owns [4]bool // nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot |
| 94 | - | inv ports.CastInventory | 98 | + | inv ports.CastInventory |
| 95 | - | haveInv bool | 99 | + | haveInv bool |
| 96 | - | callers [maxCallers]caller | 100 | + | callers [maxCallers]caller |
| 97 | - | pending [maxPending]pending | 101 | + | pending [maxPending]pending |
| 98 | - | sessions [maxSessions]session | 102 | + | // sessions is a pointer so that a shutdown that had to leave a PipeWire |
| 103 | + | // source behind can abandon the table: the source's handler still | |||
| 104 | + | // points into it, so it is never reset or reused. | |||
| 105 | + | sessions *[maxSessions]session | |||
| 99 | nextSess uint32 | 106 | nextSess uint32 | ||
| 100 | nextStrm uint32 | 107 | nextStrm uint32 | ||
| 101 | nextCast uint64 | 108 | nextCast uint64 | ||
| 102 | waitFor time.Duration // time to the next paced frame, 0 when none | 109 | waitFor time.Duration // time to the next paced frame, 0 when none | ||
| 103 | shot shotState | 110 | shot shotState | ||
| 111 | + | shotSeq uint64 // ID of the last screenshot job; never reset | |||
| 104 | fatal error | 112 | fatal error | ||
| 105 | jobs sync.WaitGroup | 113 | jobs sync.WaitGroup | ||
| 114 | + | drain time.Duration // how long shutdown waits for frames in flight | |||
| 106 | } | 115 | } | ||
| 107 | 116 | ||||
| 108 | // New creates the service. policy returns the capture allowlist in force; | 117 | // New creates the service. policy returns the capture allowlist in force; | ||
| @@ -118,6 +127,9 @@ func New(policy func() *captureallow.Policy, cmds chan<- ports.CastCommand, log | |||||
| 118 | peers: pidfdPeers{}, | 127 | peers: pidfdPeers{}, | ||
| 119 | queue: make([]ports.CastEvent, 0, sinkCap), | 128 | queue: make([]ports.CastEvent, 0, sinkCap), | ||
| 120 | spare: make([]ports.CastEvent, 0, sinkCap), | 129 | spare: make([]ports.CastEvent, 0, sinkCap), | ||
| 130 | + | ||||
| 131 | + | sessions: new([maxSessions]session), | |||
| 132 | + | drain: shutdownWait, | |||
| 121 | } | 133 | } | ||
| 122 | s.poller, s.initErr = fdwait.New() | 134 | s.poller, s.initErr = fdwait.New() | ||
| 123 | return s | 135 | return s | ||
| @@ -204,6 +216,9 @@ func (s *Service) attempt(ctx context.Context) (err error) { | |||||
| 204 | return err | 216 | return err | ||
| 205 | } | 217 | } | ||
| 206 | defer func() { _ = conn.Close() }() | 218 | defer func() { _ = conn.Close() }() | ||
| 219 | + | // The screenshot job of this attempt ends with it. | |||
| 220 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 221 | + | defer cancel() | |||
| 207 | s.reset(ctx, conn) | 222 | s.reset(ctx, conn) | ||
| 208 | if s.pwc, err = s.pw.Open(); err != nil { | 223 | if s.pwc, err = s.pw.Open(); err != nil { | ||
| 209 | s.pwc = nil | 224 | s.pwc = nil | ||
| @@ -223,7 +238,7 @@ func (s *Service) reset(ctx context.Context, conn bus) { | |||||
| 223 | s.haveInv = false | 238 | s.haveInv = false | ||
| 224 | s.callers = [maxCallers]caller{} | 239 | s.callers = [maxCallers]caller{} | ||
| 225 | s.pending = [maxPending]pending{} | 240 | s.pending = [maxPending]pending{} | ||
| 226 | - | s.sessions = [maxSessions]session{} | 241 | + | *s.sessions = [maxSessions]session{} |
| 227 | s.shot = shotState{} | 242 | s.shot = shotState{} | ||
| 228 | } | 243 | } | ||
| 229 | 244 | ||||
| @@ -326,24 +341,38 @@ func (s *Service) service() { | |||||
| 326 | s.reap() | 341 | s.reap() | ||
| 327 | } | 342 | } | ||
| 328 | 343 | ||||
| 329 | - | // shutdown ends every session and releases PipeWire. A frame still in | 344 | + | // shutdown ends the attempt: the screenshot job is stopped and joined, every |
| 330 | - | // flight is waited for: its buffer is lent to the wayland server. | 345 | + | // session is closed, and every stream is pumped until it released its |
| 346 | + | // resources: CastClose goes to the wayland server, a frame still in flight | |||
| 347 | + | // is waited for (its buffer is lent to the wayland server), then the source | |||
| 348 | + | // is destroyed and its buffers released. Only then is PipeWire closed. | |||
| 349 | + | // | |||
| 350 | + | // When a frame does not come back in time, the source cannot be destroyed | |||
| 351 | + | // safely. It is left as it is, with its loop: the sessions table that its | |||
| 352 | + | // handler points into is abandoned (never reset or reused) and a new one | |||
| 353 | + | // takes its place, so later attempts neither touch that storage nor run out | |||
| 354 | + | // of slots. | |||
| 331 | func (s *Service) shutdown() { | 355 | func (s *Service) shutdown() { | ||
| 356 | + | s.stopShot() | |||
| 332 | s.forEachSession(func(sess *session) { s.closeSession(sess, true) }) | 357 | s.forEachSession(func(sess *session) { s.closeSession(sess, true) }) | ||
| 333 | - | deadline := time.Now().Add(shutdownWait) | 358 | + | deadline := time.Now().Add(s.drain) |
| 334 | - | for s.streamsLive() && time.Now().Before(deadline) { | 359 | + | for { |
| 335 | evs, _, _, _ := s.take() | 360 | evs, _, _, _ := s.take() | ||
| 336 | for i := range evs { | 361 | for i := range evs { | ||
| 337 | s.onCastEvent(&evs[i]) | 362 | s.onCastEvent(&evs[i]) | ||
| 338 | } | 363 | } | ||
| 364 | + | s.pumpAll(s.clock.Now()) | |||
| 339 | s.reap() | 365 | s.reap() | ||
| 340 | - | if s.streamsLive() { | 366 | + | if !s.streamsLive() { |
| 341 | - | time.Sleep(5 * time.Millisecond) | 367 | + | break |
| 342 | } | 368 | } | ||
| 343 | - | } | 369 | + | if !time.Now().Before(deadline) { |
| 344 | - | if s.streamsLive() { | 370 | + | s.log.Warn().Int("streams", s.streamCount()).Msg("frames still in flight at shutdown; PipeWire resources are left to the process exit") |
| 345 | - | s.log.Warn().Msg("frames still in flight at shutdown; PipeWire resources are left to the process exit") | 371 | + | s.sessions = new([maxSessions]session) |
| 346 | - | s.pwc = nil | 372 | + | s.pwc = nil |
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | time.Sleep(shutdownPoll) | |||
| 347 | } | 376 | } | ||
| 348 | if s.pwc != nil { | 377 | if s.pwc != nil { | ||
| 349 | s.pwc.Close() | 378 | s.pwc.Close() | ||
M internal/adapters/screencast/screenshot.go +87 -31
| @@ -15,24 +15,34 @@ import ( | |||
| 15 | 15 | // InteractiveScreenshot nefercap shot -file F | |
| 16 | 16 | // | |
| 17 | 17 | // F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most | |
| 18 | - | // is in flight. The run is a goroutine; its result goes back through the | |
| 19 | - | // service's transport and the owner sends the reply, so the bus is still | |
| 20 | - | // used by the owner only. SelectArea and ScreenshotWindow (the "Select | |
| 18 | + | // is in flight. The run is a goroutine; its result, tagged with the ID of | |
| 19 | + | // the job, goes back through the service's transport and the owner sends | |
| 20 | + | // the reply, so the bus is still used by the owner only. A result whose ID | |
| 21 | + | // is not the job in flight is dropped. A job belongs to one bus attempt: | |
| 22 | + | // when the attempt ends, the job is cancelled and joined before the state is | |
| 23 | + | // reset (stopShot). A run that exits cleanly is a success only if it left a | |
| 24 | + | // non-empty regular file at F; otherwise the reply is (false, ""), which is | |
| 25 | + | // also how a cancelled interactive screenshot is answered. SelectArea and ScreenshotWindow (the "Select | |
| 21 | 26 | // area" and "Window" choices of the GNOME dialog) are not supported; | |
| 22 | 27 | // include_cursor and flash are ignored. | |
| 23 | 28 | ||
| 24 | 29 | // shotState is the screenshot in flight; one at most. | |
| 25 | 30 | type shotState struct { | |
| 26 | - | busy bool | |
| 27 | - | req request | |
| 28 | - | uri bool // reply with a file:// URI | |
| 29 | - | path string | |
| 31 | + | busy bool | |
| 32 | + | id uint64 // names the job; results of other jobs are stale | |
| 33 | + | req request | |
| 34 | + | uri bool // reply with a file:// URI | |
| 35 | + | path string | |
| 36 | + | cancel context.CancelFunc // stops the run | |
| 37 | + | done chan struct{} // closed when the job goroutine is past its result | |
| 30 | 38 | } | |
| 31 | 39 | ||
| 32 | 40 | // shotResult is what a finished nefercap run leaves for the owner. | |
| 33 | 41 | type shotResult struct { | |
| 34 | 42 | ready bool | |
| 35 | - | err error | |
| 43 | + | id uint64 // the job it belongs to | |
| 44 | + | err error // nefercap failed | |
| 45 | + | made bool // the output file exists: a regular, non-empty file | |
| 36 | 46 | } | |
| 37 | 47 | ||
| 38 | 48 | // screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot. | |
| @@ -74,8 +84,9 @@ func (s *Service) screenshot(req *request) { | |||
| 74 | 84 | s.replyErr(req, errFailed, "screenshot directory not created") | |
| 75 | 85 | return | |
| 76 | 86 | } | |
| 77 | - | s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path} | |
| 78 | - | s.runShot(s.ctx, args) | |
| 87 | + | s.shotSeq++ | |
| 88 | + | s.shot = shotState{busy: true, id: s.shotSeq, req: *req, uri: req.method == mShotInteractive, path: path} | |
| 89 | + | s.runShot(args) | |
| 79 | 90 | } | |
| 80 | 91 | ||
| 81 | 92 | // shotDir is $XDG_RUNTIME_DIR/neferwl. | |
| @@ -105,44 +116,89 @@ func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) { | |||
| 105 | 116 | return "", "", false | |
| 106 | 117 | } | |
| 107 | 118 | ||
| 108 | - | // runShot runs nefercap in a goroutine and leaves the result for the owner. | |
| 109 | - | func (s *Service) runShot(parent context.Context, args []string) { | |
| 119 | + | // runShot runs nefercap in a goroutine for the job in s.shot and leaves the | |
| 120 | + | // tagged result for the owner. The run ends with the attempt's context. | |
| 121 | + | func (s *Service) runShot(args []string) { | |
| 122 | + | ctx, cancel := context.WithTimeout(s.ctx, shotTimeout) | |
| 123 | + | done := make(chan struct{}) | |
| 124 | + | s.shot.cancel, s.shot.done = cancel, done | |
| 125 | + | id, path := s.shot.id, s.shot.path | |
| 126 | + | run := s.runner | |
| 110 | 127 | s.jobs.Add(1) | |
| 111 | 128 | go func() { | |
| 112 | 129 | defer s.jobs.Done() | |
| 113 | - | ctx, cancel := context.WithTimeout(parent, shotTimeout) | |
| 130 | + | defer close(done) | |
| 114 | 131 | defer cancel() | |
| 115 | - | err := s.runner.Run(ctx, args) | |
| 132 | + | err := run.Run(ctx, args) | |
| 133 | + | res := shotResult{ready: true, id: id, err: err} | |
| 134 | + | if err == nil { | |
| 135 | + | res.made = isOutputFile(path) | |
| 136 | + | } | |
| 116 | 137 | s.mu.Lock() | |
| 117 | - | s.shotRes = shotResult{ready: true, err: err} | |
| 138 | + | s.shotRes = res | |
| 118 | 139 | s.woken = true | |
| 119 | 140 | s.mu.Unlock() | |
| 120 | 141 | s.wake() | |
| 121 | 142 | }() | |
| 122 | 143 | } | |
| 123 | 144 | ||
| 124 | - | // onShotDone sends the reply of the screenshot that finished. | |
| 125 | - | func (s *Service) onShotDone(r *shotResult) { | |
| 145 | + | // isOutputFile reports whether path is a regular, non-empty file. A symlink | |
| 146 | + | // is not followed: it is not a regular file. | |
| 147 | + | func isOutputFile(path string) bool { | |
| 148 | + | fi, err := os.Lstat(path) | |
| 149 | + | return err == nil && fi.Mode().IsRegular() && fi.Size() > 0 | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // stopShot cancels the screenshot in flight, waits for its goroutine and | |
| 153 | + | // forgets its result, so no job outlives the attempt that started it. The | |
| 154 | + | // requester is told it failed when the bus still carries the answer. | |
| 155 | + | func (s *Service) stopShot() { | |
| 126 | 156 | st := s.shot | |
| 127 | - | s.shot = shotState{} | |
| 128 | 157 | if !st.busy { | |
| 129 | 158 | return | |
| 130 | 159 | } | |
| 131 | - | if r.err != nil { | |
| 160 | + | st.cancel() | |
| 161 | + | <-st.done | |
| 162 | + | s.mu.Lock() | |
| 163 | + | s.shotRes = shotResult{} | |
| 164 | + | s.mu.Unlock() | |
| 165 | + | s.shot = shotState{} | |
| 166 | + | _ = os.Remove(st.path) | |
| 167 | + | s.replyErr(&st.req, errFailed, "screenshot cancelled") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // onShotDone sends the reply of the screenshot that finished. A result of | |
| 171 | + | // another job than the one in flight is stale and completes nothing. | |
| 172 | + | func (s *Service) onShotDone(r *shotResult) { | |
| 173 | + | st := s.shot | |
| 174 | + | if !st.busy || r.id != st.id { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | s.shot = shotState{} | |
| 178 | + | switch { | |
| 179 | + | case r.err != nil: | |
| 132 | 180 | s.log.Warn().Err(r.err).Msg("screenshot failed") | |
| 133 | 181 | _ = os.Remove(st.path) | |
| 134 | 182 | s.replyErr(&st.req, errFailed, "screenshot failed") | |
| 135 | - | return | |
| 136 | - | } | |
| 137 | - | out := st.path | |
| 138 | - | if st.uri { | |
| 139 | - | out = "file://" + out | |
| 140 | - | } | |
| 141 | - | e := s.replyTo(&st.req, "bs") | |
| 142 | - | if e == nil { | |
| 143 | - | return | |
| 183 | + | case !r.made: | |
| 184 | + | // nefercap exited cleanly and left nothing usable (an interactive | |
| 185 | + | // screenshot that was cancelled, for one). | |
| 186 | + | s.log.Info().Msg("screenshot produced no file") | |
| 187 | + | _ = os.Remove(st.path) | |
| 188 | + | if e := s.replyTo(&st.req, "bs"); e != nil { | |
| 189 | + | e.Bool(false) | |
| 190 | + | e.Str("") | |
| 191 | + | s.send() | |
| 192 | + | } | |
| 193 | + | default: | |
| 194 | + | out := st.path | |
| 195 | + | if st.uri { | |
| 196 | + | out = "file://" + out | |
| 197 | + | } | |
| 198 | + | if e := s.replyTo(&st.req, "bs"); e != nil { | |
| 199 | + | e.Bool(true) | |
| 200 | + | e.Str(out) | |
| 201 | + | s.send() | |
| 202 | + | } | |
| 144 | 203 | } | |
| 145 | - | e.Bool(true) | |
| 146 | - | e.Str(out) | |
| 147 | - | s.send() | |
| 148 | 204 | } | |
| @@ -15,24 +15,34 @@ import ( | |||||
| 15 | // InteractiveScreenshot nefercap shot -file F | 15 | // InteractiveScreenshot nefercap shot -file F | ||
| 16 | // | 16 | // | ||
| 17 | // F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most | 17 | // F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most | ||
| 18 | - | // is in flight. The run is a goroutine; its result goes back through the | 18 | + | // is in flight. The run is a goroutine; its result, tagged with the ID of |
| 19 | - | // service's transport and the owner sends the reply, so the bus is still | 19 | + | // the job, goes back through the service's transport and the owner sends |
| 20 | - | // used by the owner only. SelectArea and ScreenshotWindow (the "Select | 20 | + | // the reply, so the bus is still used by the owner only. A result whose ID |
| 21 | + | // is not the job in flight is dropped. A job belongs to one bus attempt: | |||
| 22 | + | // when the attempt ends, the job is cancelled and joined before the state is | |||
| 23 | + | // reset (stopShot). A run that exits cleanly is a success only if it left a | |||
| 24 | + | // non-empty regular file at F; otherwise the reply is (false, ""), which is | |||
| 25 | + | // also how a cancelled interactive screenshot is answered. SelectArea and ScreenshotWindow (the "Select | |||
| 21 | // area" and "Window" choices of the GNOME dialog) are not supported; | 26 | // area" and "Window" choices of the GNOME dialog) are not supported; | ||
| 22 | // include_cursor and flash are ignored. | 27 | // include_cursor and flash are ignored. | ||
| 23 | 28 | ||||
| 24 | // shotState is the screenshot in flight; one at most. | 29 | // shotState is the screenshot in flight; one at most. | ||
| 25 | type shotState struct { | 30 | type shotState struct { | ||
| 26 | - | busy bool | 31 | + | busy bool |
| 27 | - | req request | 32 | + | id uint64 // names the job; results of other jobs are stale |
| 28 | - | uri bool // reply with a file:// URI | 33 | + | req request |
| 29 | - | path string | 34 | + | uri bool // reply with a file:// URI |
| 35 | + | path string | |||
| 36 | + | cancel context.CancelFunc // stops the run | |||
| 37 | + | done chan struct{} // closed when the job goroutine is past its result | |||
| 30 | } | 38 | } | ||
| 31 | 39 | ||||
| 32 | // shotResult is what a finished nefercap run leaves for the owner. | 40 | // shotResult is what a finished nefercap run leaves for the owner. | ||
| 33 | type shotResult struct { | 41 | type shotResult struct { | ||
| 34 | ready bool | 42 | ready bool | ||
| 35 | - | err error | 43 | + | id uint64 // the job it belongs to |
| 44 | + | err error // nefercap failed | |||
| 45 | + | made bool // the output file exists: a regular, non-empty file | |||
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 38 | // screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot. | 48 | // screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot. | ||
| @@ -74,8 +84,9 @@ func (s *Service) screenshot(req *request) { | |||||
| 74 | s.replyErr(req, errFailed, "screenshot directory not created") | 84 | s.replyErr(req, errFailed, "screenshot directory not created") | ||
| 75 | return | 85 | return | ||
| 76 | } | 86 | } | ||
| 77 | - | s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path} | 87 | + | s.shotSeq++ |
| 78 | - | s.runShot(s.ctx, args) | 88 | + | s.shot = shotState{busy: true, id: s.shotSeq, req: *req, uri: req.method == mShotInteractive, path: path} |
| 89 | + | s.runShot(args) | |||
| 79 | } | 90 | } | ||
| 80 | 91 | ||||
| 81 | // shotDir is $XDG_RUNTIME_DIR/neferwl. | 92 | // shotDir is $XDG_RUNTIME_DIR/neferwl. | ||
| @@ -105,44 +116,89 @@ func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) { | |||||
| 105 | return "", "", false | 116 | return "", "", false | ||
| 106 | } | 117 | } | ||
| 107 | 118 | ||||
| 108 | - | // runShot runs nefercap in a goroutine and leaves the result for the owner. | 119 | + | // runShot runs nefercap in a goroutine for the job in s.shot and leaves the |
| 109 | - | func (s *Service) runShot(parent context.Context, args []string) { | 120 | + | // tagged result for the owner. The run ends with the attempt's context. |
| 121 | + | func (s *Service) runShot(args []string) { | |||
| 122 | + | ctx, cancel := context.WithTimeout(s.ctx, shotTimeout) | |||
| 123 | + | done := make(chan struct{}) | |||
| 124 | + | s.shot.cancel, s.shot.done = cancel, done | |||
| 125 | + | id, path := s.shot.id, s.shot.path | |||
| 126 | + | run := s.runner | |||
| 110 | s.jobs.Add(1) | 127 | s.jobs.Add(1) | ||
| 111 | go func() { | 128 | go func() { | ||
| 112 | defer s.jobs.Done() | 129 | defer s.jobs.Done() | ||
| 113 | - | ctx, cancel := context.WithTimeout(parent, shotTimeout) | 130 | + | defer close(done) |
| 114 | defer cancel() | 131 | defer cancel() | ||
| 115 | - | err := s.runner.Run(ctx, args) | 132 | + | err := run.Run(ctx, args) |
| 133 | + | res := shotResult{ready: true, id: id, err: err} | |||
| 134 | + | if err == nil { | |||
| 135 | + | res.made = isOutputFile(path) | |||
| 136 | + | } | |||
| 116 | s.mu.Lock() | 137 | s.mu.Lock() | ||
| 117 | - | s.shotRes = shotResult{ready: true, err: err} | 138 | + | s.shotRes = res |
| 118 | s.woken = true | 139 | s.woken = true | ||
| 119 | s.mu.Unlock() | 140 | s.mu.Unlock() | ||
| 120 | s.wake() | 141 | s.wake() | ||
| 121 | }() | 142 | }() | ||
| 122 | } | 143 | } | ||
| 123 | 144 | ||||
| 124 | - | // onShotDone sends the reply of the screenshot that finished. | 145 | + | // isOutputFile reports whether path is a regular, non-empty file. A symlink |
| 125 | - | func (s *Service) onShotDone(r *shotResult) { | 146 | + | // is not followed: it is not a regular file. |
| 147 | + | func isOutputFile(path string) bool { | |||
| 148 | + | fi, err := os.Lstat(path) | |||
| 149 | + | return err == nil && fi.Mode().IsRegular() && fi.Size() > 0 | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // stopShot cancels the screenshot in flight, waits for its goroutine and | |||
| 153 | + | // forgets its result, so no job outlives the attempt that started it. The | |||
| 154 | + | // requester is told it failed when the bus still carries the answer. | |||
| 155 | + | func (s *Service) stopShot() { | |||
| 126 | st := s.shot | 156 | st := s.shot | ||
| 127 | - | s.shot = shotState{} | |||
| 128 | if !st.busy { | 157 | if !st.busy { | ||
| 129 | return | 158 | return | ||
| 130 | } | 159 | } | ||
| 131 | - | if r.err != nil { | 160 | + | st.cancel() |
| 161 | + | <-st.done | |||
| 162 | + | s.mu.Lock() | |||
| 163 | + | s.shotRes = shotResult{} | |||
| 164 | + | s.mu.Unlock() | |||
| 165 | + | s.shot = shotState{} | |||
| 166 | + | _ = os.Remove(st.path) | |||
| 167 | + | s.replyErr(&st.req, errFailed, "screenshot cancelled") | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // onShotDone sends the reply of the screenshot that finished. A result of | |||
| 171 | + | // another job than the one in flight is stale and completes nothing. | |||
| 172 | + | func (s *Service) onShotDone(r *shotResult) { | |||
| 173 | + | st := s.shot | |||
| 174 | + | if !st.busy || r.id != st.id { | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | s.shot = shotState{} | |||
| 178 | + | switch { | |||
| 179 | + | case r.err != nil: | |||
| 132 | s.log.Warn().Err(r.err).Msg("screenshot failed") | 180 | s.log.Warn().Err(r.err).Msg("screenshot failed") | ||
| 133 | _ = os.Remove(st.path) | 181 | _ = os.Remove(st.path) | ||
| 134 | s.replyErr(&st.req, errFailed, "screenshot failed") | 182 | s.replyErr(&st.req, errFailed, "screenshot failed") | ||
| 135 | - | return | 183 | + | case !r.made: |
| 136 | - | } | 184 | + | // nefercap exited cleanly and left nothing usable (an interactive |
| 137 | - | out := st.path | 185 | + | // screenshot that was cancelled, for one). |
| 138 | - | if st.uri { | 186 | + | s.log.Info().Msg("screenshot produced no file") |
| 139 | - | out = "file://" + out | 187 | + | _ = os.Remove(st.path) |
| 140 | - | } | 188 | + | if e := s.replyTo(&st.req, "bs"); e != nil { |
| 141 | - | e := s.replyTo(&st.req, "bs") | 189 | + | e.Bool(false) |
| 142 | - | if e == nil { | 190 | + | e.Str("") |
| 143 | - | return | 191 | + | s.send() |
| 192 | + | } | |||
| 193 | + | default: | |||
| 194 | + | out := st.path | |||
| 195 | + | if st.uri { | |||
| 196 | + | out = "file://" + out | |||
| 197 | + | } | |||
| 198 | + | if e := s.replyTo(&st.req, "bs"); e != nil { | |||
| 199 | + | e.Bool(true) | |||
| 200 | + | e.Str(out) | |||
| 201 | + | s.send() | |||
| 202 | + | } | |||
| 144 | } | 203 | } | ||
| 145 | - | e.Bool(true) | |||
| 146 | - | e.Str(out) | |||
| 147 | - | s.send() | |||
| 148 | } | 204 | } | ||
M internal/adapters/screencast/screenshot_test.go +114 -1
| @@ -12,6 +12,19 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | // writeShot is what nefercap does: it writes the PNG named by -file. | |
| 16 | + | func writeShot(t *testing.T, args []string) { | |
| 17 | + | t.Helper() | |
| 18 | + | i := slices.Index(args, "-file") | |
| 19 | + | if i < 0 || i+1 >= len(args) { | |
| 20 | + | t.Errorf("no -file in %v", args) | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | if err := os.WriteFile(args[i+1], []byte("\x89PNG"), 0o600); err != nil { | |
| 24 | + | t.Error(err) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 15 | 28 | // Screenshot: nefercap on the focused output; the reply carries the file. | |
| 16 | 29 | func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) { | |
| 17 | 30 | rt := t.TempDir() | |
| @@ -20,6 +33,7 @@ func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) { | |||
| 20 | 33 | got := make(chan []string, 1) | |
| 21 | 34 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |
| 22 | 35 | got <- slices.Clone(args) | |
| 36 | + | writeShot(t, args) | |
| 23 | 37 | return nil | |
| 24 | 38 | }).Once() | |
| 25 | 39 | name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { | |
| @@ -53,6 +67,7 @@ func TestScreenshotAreaMapsToRegion(t *testing.T) { | |||
| 53 | 67 | got := make(chan []string, 1) | |
| 54 | 68 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |
| 55 | 69 | got <- slices.Clone(args) | |
| 70 | + | writeShot(t, args) | |
| 56 | 71 | return nil | |
| 57 | 72 | }).Once() | |
| 58 | 73 | area := func(x, y, w, hh int32) func(*zerobus.Encoder) { | |
| @@ -91,7 +106,11 @@ func TestInteractiveScreenshotBusyAndFailure(t *testing.T) { | |||
| 91 | 106 | got := make(chan []string, 2) | |
| 92 | 107 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |
| 93 | 108 | got <- slices.Clone(args) | |
| 94 | - | return <-release | |
| 109 | + | err := <-release | |
| 110 | + | if err == nil { | |
| 111 | + | writeShot(t, args) | |
| 112 | + | } | |
| 113 | + | return err | |
| 95 | 114 | }).Twice() | |
| 96 | 115 | ||
| 97 | 116 | c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "") | |
| @@ -140,3 +159,97 @@ func TestScreenshotUnsupportedMethods(t *testing.T) { | |||
| 140 | 159 | } | |
| 141 | 160 | } | |
| 142 | 161 | } | |
| 162 | + | ||
| 163 | + | // nefercap that exits cleanly without leaving a regular, non-empty file is | |
| 164 | + | // not a success: the file is missing, empty, or a symlink. | |
| 165 | + | func TestScreenshotWithoutUsableFileIsNotSuccess(t *testing.T) { | |
| 166 | + | rt := t.TempDir() | |
| 167 | + | t.Setenv("XDG_RUNTIME_DIR", rt) | |
| 168 | + | target := filepath.Join(rt, "real.png") | |
| 169 | + | if err := os.WriteFile(target, []byte("png"), 0o600); err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | for name, mk := range map[string]func(path string){ | |
| 173 | + | "missing": func(string) {}, | |
| 174 | + | "empty": func(path string) { _ = os.WriteFile(path, nil, 0o600) }, | |
| 175 | + | "symlink": func(path string) { _ = os.Symlink(target, path) }, | |
| 176 | + | "dir": func(path string) { _ = os.Mkdir(path, 0o700) }, | |
| 177 | + | } { | |
| 178 | + | t.Run(name, func(t *testing.T) { | |
| 179 | + | h, c := ready(t, harnessOpts{}) | |
| 180 | + | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |
| 181 | + | mk(args[slices.Index(args, "-file")+1]) | |
| 182 | + | return nil | |
| 183 | + | }).Once() | |
| 184 | + | for _, member := range []string{"Screenshot", "InteractiveScreenshot"} { | |
| 185 | + | if member == "InteractiveScreenshot" { | |
| 186 | + | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |
| 187 | + | mk(args[slices.Index(args, "-file")+1]) | |
| 188 | + | return nil | |
| 189 | + | }).Once() | |
| 190 | + | } | |
| 191 | + | sig := "" | |
| 192 | + | body := func(*zerobus.Encoder) {} | |
| 193 | + | if member == "Screenshot" { | |
| 194 | + | sig = "bbs" | |
| 195 | + | body = func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") } | |
| 196 | + | } | |
| 197 | + | errName, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, sig, body) | |
| 198 | + | if errName != "" { | |
| 199 | + | t.Fatalf("%s: %s", member, errName) | |
| 200 | + | } | |
| 201 | + | r := m.Body() | |
| 202 | + | if ok, file := r.Bool(), r.Str(); ok || file != "" { | |
| 203 | + | t.Fatalf("%s: success %v %q without a file", member, ok, file) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | }) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // A result that belongs to another job completes nothing, and a job that | |
| 211 | + | // the attempt's end cancels is joined before its state is reset. | |
| 212 | + | func TestStaleScreenshotResultIsDropped(t *testing.T) { | |
| 213 | + | p := newProducer(t) | |
| 214 | + | run := newMockrunner(t) | |
| 215 | + | p.s.runner = run | |
| 216 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 217 | + | defer cancel() | |
| 218 | + | p.s.ctx = ctx | |
| 219 | + | started := make(chan struct{}) | |
| 220 | + | var seen error | |
| 221 | + | run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(c context.Context, _ []string) error { | |
| 222 | + | close(started) | |
| 223 | + | <-c.Done() | |
| 224 | + | seen = c.Err() | |
| 225 | + | return c.Err() | |
| 226 | + | }).Once() | |
| 227 | + | p.bus.EXPECT().NewError(mock.Anything, errFailed, "s").RunAndReturn( | |
| 228 | + | func(*zerobus.Message, string, string) *zerobus.Encoder { return &zerobus.Encoder{} }).Once() | |
| 229 | + | ||
| 230 | + | p.s.shotSeq++ | |
| 231 | + | p.s.shot = shotState{busy: true, id: p.s.shotSeq, path: filepath.Join(t.TempDir(), "x.png")} | |
| 232 | + | p.s.runShot([]string{"shot"}) | |
| 233 | + | <-started | |
| 234 | + | // A result of some earlier job is in the transport: it is not this job's. | |
| 235 | + | p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true}) | |
| 236 | + | if !p.s.shot.busy { | |
| 237 | + | t.Fatal("a stale result completed the job in flight") | |
| 238 | + | } | |
| 239 | + | // The attempt ends: the job is cancelled and joined before the reset. | |
| 240 | + | p.s.stopShot() | |
| 241 | + | if seen == nil { | |
| 242 | + | t.Fatal("stopShot returned before the job saw its cancellation") | |
| 243 | + | } | |
| 244 | + | if p.s.shot.busy || p.s.shotRes.ready { | |
| 245 | + | t.Fatal("job state survives the attempt") | |
| 246 | + | } | |
| 247 | + | // Its own late result (not possible after the join, but harmless) and a | |
| 248 | + | // new job with a new ID: the first job's ID completes nothing. | |
| 249 | + | p.s.shotSeq++ | |
| 250 | + | p.s.shot = shotState{busy: true, id: p.s.shotSeq} | |
| 251 | + | p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true}) | |
| 252 | + | if !p.s.shot.busy { | |
| 253 | + | t.Fatal("a result of the cancelled job completed the next request") | |
| 254 | + | } | |
| 255 | + | } | |
| @@ -12,6 +12,19 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | // writeShot is what nefercap does: it writes the PNG named by -file. | |||
| 16 | + | func writeShot(t *testing.T, args []string) { | |||
| 17 | + | t.Helper() | |||
| 18 | + | i := slices.Index(args, "-file") | |||
| 19 | + | if i < 0 || i+1 >= len(args) { | |||
| 20 | + | t.Errorf("no -file in %v", args) | |||
| 21 | + | return | |||
| 22 | + | } | |||
| 23 | + | if err := os.WriteFile(args[i+1], []byte("\x89PNG"), 0o600); err != nil { | |||
| 24 | + | t.Error(err) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 15 | // Screenshot: nefercap on the focused output; the reply carries the file. | 28 | // Screenshot: nefercap on the focused output; the reply carries the file. | ||
| 16 | func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) { | 29 | func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) { | ||
| 17 | rt := t.TempDir() | 30 | rt := t.TempDir() | ||
| @@ -20,6 +33,7 @@ func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) { | |||||
| 20 | got := make(chan []string, 1) | 33 | got := make(chan []string, 1) | ||
| 21 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | 34 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | ||
| 22 | got <- slices.Clone(args) | 35 | got <- slices.Clone(args) | ||
| 36 | + | writeShot(t, args) | |||
| 23 | return nil | 37 | return nil | ||
| 24 | }).Once() | 38 | }).Once() | ||
| 25 | name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { | 39 | name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { | ||
| @@ -53,6 +67,7 @@ func TestScreenshotAreaMapsToRegion(t *testing.T) { | |||||
| 53 | got := make(chan []string, 1) | 67 | got := make(chan []string, 1) | ||
| 54 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | 68 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | ||
| 55 | got <- slices.Clone(args) | 69 | got <- slices.Clone(args) | ||
| 70 | + | writeShot(t, args) | |||
| 56 | return nil | 71 | return nil | ||
| 57 | }).Once() | 72 | }).Once() | ||
| 58 | area := func(x, y, w, hh int32) func(*zerobus.Encoder) { | 73 | area := func(x, y, w, hh int32) func(*zerobus.Encoder) { | ||
| @@ -91,7 +106,11 @@ func TestInteractiveScreenshotBusyAndFailure(t *testing.T) { | |||||
| 91 | got := make(chan []string, 2) | 106 | got := make(chan []string, 2) | ||
| 92 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | 107 | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | ||
| 93 | got <- slices.Clone(args) | 108 | got <- slices.Clone(args) | ||
| 94 | - | return <-release | 109 | + | err := <-release |
| 110 | + | if err == nil { | |||
| 111 | + | writeShot(t, args) | |||
| 112 | + | } | |||
| 113 | + | return err | |||
| 95 | }).Twice() | 114 | }).Twice() | ||
| 96 | 115 | ||||
| 97 | c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "") | 116 | c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "") | ||
| @@ -140,3 +159,97 @@ func TestScreenshotUnsupportedMethods(t *testing.T) { | |||||
| 140 | } | 159 | } | ||
| 141 | } | 160 | } | ||
| 142 | } | 161 | } | ||
| 162 | + | ||||
| 163 | + | // nefercap that exits cleanly without leaving a regular, non-empty file is | |||
| 164 | + | // not a success: the file is missing, empty, or a symlink. | |||
| 165 | + | func TestScreenshotWithoutUsableFileIsNotSuccess(t *testing.T) { | |||
| 166 | + | rt := t.TempDir() | |||
| 167 | + | t.Setenv("XDG_RUNTIME_DIR", rt) | |||
| 168 | + | target := filepath.Join(rt, "real.png") | |||
| 169 | + | if err := os.WriteFile(target, []byte("png"), 0o600); err != nil { | |||
| 170 | + | t.Fatal(err) | |||
| 171 | + | } | |||
| 172 | + | for name, mk := range map[string]func(path string){ | |||
| 173 | + | "missing": func(string) {}, | |||
| 174 | + | "empty": func(path string) { _ = os.WriteFile(path, nil, 0o600) }, | |||
| 175 | + | "symlink": func(path string) { _ = os.Symlink(target, path) }, | |||
| 176 | + | "dir": func(path string) { _ = os.Mkdir(path, 0o700) }, | |||
| 177 | + | } { | |||
| 178 | + | t.Run(name, func(t *testing.T) { | |||
| 179 | + | h, c := ready(t, harnessOpts{}) | |||
| 180 | + | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |||
| 181 | + | mk(args[slices.Index(args, "-file")+1]) | |||
| 182 | + | return nil | |||
| 183 | + | }).Once() | |||
| 184 | + | for _, member := range []string{"Screenshot", "InteractiveScreenshot"} { | |||
| 185 | + | if member == "InteractiveScreenshot" { | |||
| 186 | + | h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error { | |||
| 187 | + | mk(args[slices.Index(args, "-file")+1]) | |||
| 188 | + | return nil | |||
| 189 | + | }).Once() | |||
| 190 | + | } | |||
| 191 | + | sig := "" | |||
| 192 | + | body := func(*zerobus.Encoder) {} | |||
| 193 | + | if member == "Screenshot" { | |||
| 194 | + | sig = "bbs" | |||
| 195 | + | body = func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") } | |||
| 196 | + | } | |||
| 197 | + | errName, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, sig, body) | |||
| 198 | + | if errName != "" { | |||
| 199 | + | t.Fatalf("%s: %s", member, errName) | |||
| 200 | + | } | |||
| 201 | + | r := m.Body() | |||
| 202 | + | if ok, file := r.Bool(), r.Str(); ok || file != "" { | |||
| 203 | + | t.Fatalf("%s: success %v %q without a file", member, ok, file) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | }) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // A result that belongs to another job completes nothing, and a job that | |||
| 211 | + | // the attempt's end cancels is joined before its state is reset. | |||
| 212 | + | func TestStaleScreenshotResultIsDropped(t *testing.T) { | |||
| 213 | + | p := newProducer(t) | |||
| 214 | + | run := newMockrunner(t) | |||
| 215 | + | p.s.runner = run | |||
| 216 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 217 | + | defer cancel() | |||
| 218 | + | p.s.ctx = ctx | |||
| 219 | + | started := make(chan struct{}) | |||
| 220 | + | var seen error | |||
| 221 | + | run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(c context.Context, _ []string) error { | |||
| 222 | + | close(started) | |||
| 223 | + | <-c.Done() | |||
| 224 | + | seen = c.Err() | |||
| 225 | + | return c.Err() | |||
| 226 | + | }).Once() | |||
| 227 | + | p.bus.EXPECT().NewError(mock.Anything, errFailed, "s").RunAndReturn( | |||
| 228 | + | func(*zerobus.Message, string, string) *zerobus.Encoder { return &zerobus.Encoder{} }).Once() | |||
| 229 | + | ||||
| 230 | + | p.s.shotSeq++ | |||
| 231 | + | p.s.shot = shotState{busy: true, id: p.s.shotSeq, path: filepath.Join(t.TempDir(), "x.png")} | |||
| 232 | + | p.s.runShot([]string{"shot"}) | |||
| 233 | + | <-started | |||
| 234 | + | // A result of some earlier job is in the transport: it is not this job's. | |||
| 235 | + | p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true}) | |||
| 236 | + | if !p.s.shot.busy { | |||
| 237 | + | t.Fatal("a stale result completed the job in flight") | |||
| 238 | + | } | |||
| 239 | + | // The attempt ends: the job is cancelled and joined before the reset. | |||
| 240 | + | p.s.stopShot() | |||
| 241 | + | if seen == nil { | |||
| 242 | + | t.Fatal("stopShot returned before the job saw its cancellation") | |||
| 243 | + | } | |||
| 244 | + | if p.s.shot.busy || p.s.shotRes.ready { | |||
| 245 | + | t.Fatal("job state survives the attempt") | |||
| 246 | + | } | |||
| 247 | + | // Its own late result (not possible after the join, but harmless) and a | |||
| 248 | + | // new job with a new ID: the first job's ID completes nothing. | |||
| 249 | + | p.s.shotSeq++ | |||
| 250 | + | p.s.shot = shotState{busy: true, id: p.s.shotSeq} | |||
| 251 | + | p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true}) | |||
| 252 | + | if !p.s.shot.busy { | |||
| 253 | + | t.Fatal("a result of the cancelled job completed the next request") | |||
| 254 | + | } | |||
| 255 | + | } | |||
M internal/adapters/screencast/seams.go +3 -0
| @@ -116,6 +116,9 @@ func (r execRunner) Run(ctx context.Context, args []string) error { | |||
| 116 | 116 | return err | |
| 117 | 117 | } | |
| 118 | 118 | cmd := exec.CommandContext(ctx, bin, args...) | |
| 119 | + | // A cancelled run is joined by the owner: never wait long for pipes | |
| 120 | + | // that a grandchild keeps open. | |
| 121 | + | cmd.WaitDelay = 2 * time.Second | |
| 119 | 122 | if len(r.env) > 0 { | |
| 120 | 123 | cmd.Env = r.env | |
| 121 | 124 | } | |
| @@ -116,6 +116,9 @@ func (r execRunner) Run(ctx context.Context, args []string) error { | |||||
| 116 | return err | 116 | return err | ||
| 117 | } | 117 | } | ||
| 118 | cmd := exec.CommandContext(ctx, bin, args...) | 118 | cmd := exec.CommandContext(ctx, bin, args...) | ||
| 119 | + | // A cancelled run is joined by the owner: never wait long for pipes | |||
| 120 | + | // that a grandchild keeps open. | |||
| 121 | + | cmd.WaitDelay = 2 * time.Second | |||
| 119 | if len(r.env) > 0 { | 122 | if len(r.env) > 0 { | ||
| 120 | cmd.Env = r.env | 123 | cmd.Env = r.env | ||
| 121 | } | 124 | } | ||
A internal/adapters/screencast/shutdown_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package screencast | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/bnema/purego-pipewire/pipewire" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // liveCast is a cast on a private bus, brought to STREAMING with a mocked | |
| 13 | + | // PipeWire source, so a test can lose the bus under it. | |
| 14 | + | type liveCast struct { | |
| 15 | + | h *svcHarness | |
| 16 | + | src *mockvideoSource | |
| 17 | + | buf *pipewire.VideoBuffer | |
| 18 | + | // onLoop runs functions inside the PipeWire loop iteration, where the | |
| 19 | + | // real handler callbacks run. | |
| 20 | + | onLoop chan func() | |
| 21 | + | hdl chan pipewire.VideoHandler | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (l *liveCast) loop(f func()) { | |
| 25 | + | l.onLoop <- f | |
| 26 | + | l.h.svc.Push(ports.CastEvent{Kind: ports.CastDamage, ID: 1 << 40}) // wakes the owner | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func startLiveCast(t *testing.T, withFrame bool) *liveCast { | |
| 30 | + | t.Helper() | |
| 31 | + | l := &liveCast{onLoop: make(chan func(), 8), hdl: make(chan pipewire.VideoHandler, 1)} | |
| 32 | + | l.h, _ = ready(t, harnessOpts{iterate: func() { | |
| 33 | + | select { | |
| 34 | + | case f := <-l.onLoop: | |
| 35 | + | f() | |
| 36 | + | default: | |
| 37 | + | } | |
| 38 | + | }}) | |
| 39 | + | h := l.h | |
| 40 | + | c := h.client() | |
| 41 | + | l.src = newMockvideoSource(t) | |
| 42 | + | l.buf = &pipewire.VideoBuffer{Index: 2, Data: make([]byte, 64*32*4), Stride: 256, Width: 64, Height: 32, Format: pipewire.VideoFormatBGRx} | |
| 43 | + | h.core.EXPECT().NewVideoSource(mock.Anything, mock.Anything).RunAndReturn( | |
| 44 | + | func(_ pipewire.VideoConfig, hd pipewire.VideoHandler) (videoSource, error) { | |
| 45 | + | l.hdl <- hd | |
| 46 | + | return l.src, nil | |
| 47 | + | }).Once() | |
| 48 | + | if withFrame { | |
| 49 | + | l.src.EXPECT().Dequeue().Return(l.buf).Once() | |
| 50 | + | } else { | |
| 51 | + | l.src.EXPECT().Dequeue().Return(nil).Maybe() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | sess := createSession(t, c) | |
| 55 | + | if name, _ := recordMonitor(c, sess, "HEADLESS-1"); name != "" { | |
| 56 | + | t.Fatal(name) | |
| 57 | + | } | |
| 58 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Start", "", nil); name != "" { | |
| 59 | + | t.Fatal(name) | |
| 60 | + | } | |
| 61 | + | if cmd := recvCmd(t, h); cmd.Kind != ports.CastOpen { | |
| 62 | + | t.Fatalf("%+v", cmd) | |
| 63 | + | } | |
| 64 | + | h.svc.Push(ports.CastEvent{Kind: ports.CastSize, ID: 1, Width: 64, Height: 32}) | |
| 65 | + | var hd pipewire.VideoHandler | |
| 66 | + | select { | |
| 67 | + | case hd = <-l.hdl: | |
| 68 | + | case <-time.After(5 * time.Second): | |
| 69 | + | t.Fatal("no PipeWire source") | |
| 70 | + | } | |
| 71 | + | l.loop(func() { | |
| 72 | + | hd.StateChanged(pipewire.StreamStatePaused, 42, "") | |
| 73 | + | hd.FormatChanged(pipewire.VideoFormatBGRx, 64, 32, 256) | |
| 74 | + | hd.StateChanged(pipewire.StreamStateStreaming, 42, "") | |
| 75 | + | }) | |
| 76 | + | if withFrame { | |
| 77 | + | if cmd := recvCmd(t, h); cmd.Kind != ports.CastFrame || cmd.Buf != 2 { | |
| 78 | + | t.Fatalf("%+v", cmd) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return l | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func recvCmd(t *testing.T, h *svcHarness) ports.CastCommand { | |
| 85 | + | t.Helper() | |
| 86 | + | select { | |
| 87 | + | case c := <-h.cmds: | |
| 88 | + | return c | |
| 89 | + | case <-time.After(5 * time.Second): | |
| 90 | + | t.Fatal("no command\n" + h.log.String()) | |
| 91 | + | return ports.CastCommand{} | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func waitClosed(t *testing.T, h *svcHarness) { | |
| 96 | + | t.Helper() | |
| 97 | + | select { | |
| 98 | + | case <-h.closed: | |
| 99 | + | case <-time.After(5 * time.Second): | |
| 100 | + | t.Fatal("PipeWire not closed\n" + h.log.String()) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Losing the bus with a live stream and no frame in flight: the cast is | |
| 105 | + | // closed on the wayland side, the source destroyed, PipeWire closed. | |
| 106 | + | func TestBusLossTearsDownStream(t *testing.T) { | |
| 107 | + | l := startLiveCast(t, false) | |
| 108 | + | l.src.EXPECT().Destroy().Return().Once() | |
| 109 | + | l.h.killBus() | |
| 110 | + | if cmd := recvCmd(t, l.h); cmd.Kind != ports.CastClose || cmd.ID != 1 { | |
| 111 | + | t.Fatalf("%+v", cmd) | |
| 112 | + | } | |
| 113 | + | waitClosed(t, l.h) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // With a frame in flight: CastClose goes out at once, but the source is | |
| 117 | + | // destroyed only after the frame's CastFrameDone, and its buffer released. | |
| 118 | + | func TestBusLossWaitsForFrameInFlight(t *testing.T) { | |
| 119 | + | l := startLiveCast(t, true) | |
| 120 | + | if cmd := func() ports.CastCommand { l.h.killBus(); return recvCmd(t, l.h) }(); cmd.Kind != ports.CastClose || cmd.ID != 1 { | |
| 121 | + | t.Fatalf("%+v", cmd) | |
| 122 | + | } | |
| 123 | + | select { | |
| 124 | + | case <-l.h.closed: | |
| 125 | + | t.Fatal("PipeWire closed with a frame in flight") | |
| 126 | + | case <-time.After(200 * time.Millisecond): | |
| 127 | + | } | |
| 128 | + | released := make(chan struct{}) | |
| 129 | + | l.src.EXPECT().Release(l.buf).Run(func(*pipewire.VideoBuffer) { close(released) }).Once() | |
| 130 | + | l.src.EXPECT().Destroy().Return().Once() | |
| 131 | + | l.h.svc.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 2}) | |
| 132 | + | waitClosed(t, l.h) | |
| 133 | + | select { | |
| 134 | + | case <-released: | |
| 135 | + | default: | |
| 136 | + | t.Fatal("buffer not released") | |
| 137 | + | } | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package screencast | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-pipewire/pipewire" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // liveCast is a cast on a private bus, brought to STREAMING with a mocked | |||
| 13 | + | // PipeWire source, so a test can lose the bus under it. | |||
| 14 | + | type liveCast struct { | |||
| 15 | + | h *svcHarness | |||
| 16 | + | src *mockvideoSource | |||
| 17 | + | buf *pipewire.VideoBuffer | |||
| 18 | + | // onLoop runs functions inside the PipeWire loop iteration, where the | |||
| 19 | + | // real handler callbacks run. | |||
| 20 | + | onLoop chan func() | |||
| 21 | + | hdl chan pipewire.VideoHandler | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (l *liveCast) loop(f func()) { | |||
| 25 | + | l.onLoop <- f | |||
| 26 | + | l.h.svc.Push(ports.CastEvent{Kind: ports.CastDamage, ID: 1 << 40}) // wakes the owner | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func startLiveCast(t *testing.T, withFrame bool) *liveCast { | |||
| 30 | + | t.Helper() | |||
| 31 | + | l := &liveCast{onLoop: make(chan func(), 8), hdl: make(chan pipewire.VideoHandler, 1)} | |||
| 32 | + | l.h, _ = ready(t, harnessOpts{iterate: func() { | |||
| 33 | + | select { | |||
| 34 | + | case f := <-l.onLoop: | |||
| 35 | + | f() | |||
| 36 | + | default: | |||
| 37 | + | } | |||
| 38 | + | }}) | |||
| 39 | + | h := l.h | |||
| 40 | + | c := h.client() | |||
| 41 | + | l.src = newMockvideoSource(t) | |||
| 42 | + | l.buf = &pipewire.VideoBuffer{Index: 2, Data: make([]byte, 64*32*4), Stride: 256, Width: 64, Height: 32, Format: pipewire.VideoFormatBGRx} | |||
| 43 | + | h.core.EXPECT().NewVideoSource(mock.Anything, mock.Anything).RunAndReturn( | |||
| 44 | + | func(_ pipewire.VideoConfig, hd pipewire.VideoHandler) (videoSource, error) { | |||
| 45 | + | l.hdl <- hd | |||
| 46 | + | return l.src, nil | |||
| 47 | + | }).Once() | |||
| 48 | + | if withFrame { | |||
| 49 | + | l.src.EXPECT().Dequeue().Return(l.buf).Once() | |||
| 50 | + | } else { | |||
| 51 | + | l.src.EXPECT().Dequeue().Return(nil).Maybe() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | sess := createSession(t, c) | |||
| 55 | + | if name, _ := recordMonitor(c, sess, "HEADLESS-1"); name != "" { | |||
| 56 | + | t.Fatal(name) | |||
| 57 | + | } | |||
| 58 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Start", "", nil); name != "" { | |||
| 59 | + | t.Fatal(name) | |||
| 60 | + | } | |||
| 61 | + | if cmd := recvCmd(t, h); cmd.Kind != ports.CastOpen { | |||
| 62 | + | t.Fatalf("%+v", cmd) | |||
| 63 | + | } | |||
| 64 | + | h.svc.Push(ports.CastEvent{Kind: ports.CastSize, ID: 1, Width: 64, Height: 32}) | |||
| 65 | + | var hd pipewire.VideoHandler | |||
| 66 | + | select { | |||
| 67 | + | case hd = <-l.hdl: | |||
| 68 | + | case <-time.After(5 * time.Second): | |||
| 69 | + | t.Fatal("no PipeWire source") | |||
| 70 | + | } | |||
| 71 | + | l.loop(func() { | |||
| 72 | + | hd.StateChanged(pipewire.StreamStatePaused, 42, "") | |||
| 73 | + | hd.FormatChanged(pipewire.VideoFormatBGRx, 64, 32, 256) | |||
| 74 | + | hd.StateChanged(pipewire.StreamStateStreaming, 42, "") | |||
| 75 | + | }) | |||
| 76 | + | if withFrame { | |||
| 77 | + | if cmd := recvCmd(t, h); cmd.Kind != ports.CastFrame || cmd.Buf != 2 { | |||
| 78 | + | t.Fatalf("%+v", cmd) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return l | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func recvCmd(t *testing.T, h *svcHarness) ports.CastCommand { | |||
| 85 | + | t.Helper() | |||
| 86 | + | select { | |||
| 87 | + | case c := <-h.cmds: | |||
| 88 | + | return c | |||
| 89 | + | case <-time.After(5 * time.Second): | |||
| 90 | + | t.Fatal("no command\n" + h.log.String()) | |||
| 91 | + | return ports.CastCommand{} | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func waitClosed(t *testing.T, h *svcHarness) { | |||
| 96 | + | t.Helper() | |||
| 97 | + | select { | |||
| 98 | + | case <-h.closed: | |||
| 99 | + | case <-time.After(5 * time.Second): | |||
| 100 | + | t.Fatal("PipeWire not closed\n" + h.log.String()) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Losing the bus with a live stream and no frame in flight: the cast is | |||
| 105 | + | // closed on the wayland side, the source destroyed, PipeWire closed. | |||
| 106 | + | func TestBusLossTearsDownStream(t *testing.T) { | |||
| 107 | + | l := startLiveCast(t, false) | |||
| 108 | + | l.src.EXPECT().Destroy().Return().Once() | |||
| 109 | + | l.h.killBus() | |||
| 110 | + | if cmd := recvCmd(t, l.h); cmd.Kind != ports.CastClose || cmd.ID != 1 { | |||
| 111 | + | t.Fatalf("%+v", cmd) | |||
| 112 | + | } | |||
| 113 | + | waitClosed(t, l.h) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // With a frame in flight: CastClose goes out at once, but the source is | |||
| 117 | + | // destroyed only after the frame's CastFrameDone, and its buffer released. | |||
| 118 | + | func TestBusLossWaitsForFrameInFlight(t *testing.T) { | |||
| 119 | + | l := startLiveCast(t, true) | |||
| 120 | + | if cmd := func() ports.CastCommand { l.h.killBus(); return recvCmd(t, l.h) }(); cmd.Kind != ports.CastClose || cmd.ID != 1 { | |||
| 121 | + | t.Fatalf("%+v", cmd) | |||
| 122 | + | } | |||
| 123 | + | select { | |||
| 124 | + | case <-l.h.closed: | |||
| 125 | + | t.Fatal("PipeWire closed with a frame in flight") | |||
| 126 | + | case <-time.After(200 * time.Millisecond): | |||
| 127 | + | } | |||
| 128 | + | released := make(chan struct{}) | |||
| 129 | + | l.src.EXPECT().Release(l.buf).Run(func(*pipewire.VideoBuffer) { close(released) }).Once() | |||
| 130 | + | l.src.EXPECT().Destroy().Return().Once() | |||
| 131 | + | l.h.svc.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 2}) | |||
| 132 | + | waitClosed(t, l.h) | |||
| 133 | + | select { | |||
| 134 | + | case <-released: | |||
| 135 | + | default: | |||
| 136 | + | t.Fatal("buffer not released") | |||
| 137 | + | } | |||
| 138 | + | } | |||