detached 88fa6a5

fix(capture): retry ext-image-copy-capture frames on transient failures

A frame whose target is not ready, whose exclusion or indicator is not confirmed, or whose renderer is busy is tried again for up to one second instead of failing with unknown, which ends the whole screencast in xdg-desktop-portal-wlr.
commit
88fa6a526915f9c08dbe1ca413d441b14071e657
author
brice · · 2026-10-03 07:08 UTC
parent
ae24f44
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +115 -21
M docs/headless.md +2 -2
@@ -9,9 +9,9 @@ WAYLAND_DISPLAY=<name from the log> foot
99
1010 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
1111
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait (their frames fail) until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
1313
14-`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
14+`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client or a stopped session fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
1515
1616 Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
1717
@@ -9,9 +9,9 @@ WAYLAND_DISPLAY=<name from the log> foot
9 9
10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
11 11
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait (their frames fail) until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
13 13
14-`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).14+`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client or a stopped session fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
15 15
16 Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.16 Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
17 17
M internal/adapters/wayland/capture_admission_test.go +7 -2
@@ -189,7 +189,9 @@ func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.Captu
189189 h.held = append(h.held, req)
190190 return req, true
191191 default:
192- if last != p.failed {
192+ // A refused wlr frame fails at once; an ext frame waits for a
193+ // retry (captureRetryFor) rather than ending the client's stream.
194+ if last != p.failed && (!p.ext || last != 0) {
193195 t.Fatalf("refused capture not failed: last event %d", last)
194196 }
195197 return ports.CaptureRequest{}, false
@@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) {
287289 }
288290 a.destroy(t, p)
289291 h.waitInflight(t, maxCaptureInflight)
290- if _, ok := h.accepted(t, a.captureExt(t)); ok {
292+ p = a.captureExt(t)
293+ if _, ok := h.accepted(t, p); ok {
291294 t.Fatal("cap exceeded after ext refill")
292295 }
296+ // A live refused frame would retry into the freed credit: drop it.
297+ a.destroy(t, p)
293298 h.complete(t, req, maxCaptureInflight-1)
294299 for i, r := range reqs[1:] {
295300 h.complete(t, r, maxCaptureInflight-2-i)
@@ -189,7 +189,9 @@ func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.Captu
189 h.held = append(h.held, req)189 h.held = append(h.held, req)
190 return req, true190 return req, true
191 default:191 default:
192- if last != p.failed {192+ // A refused wlr frame fails at once; an ext frame waits for a
193+ // retry (captureRetryFor) rather than ending the client's stream.
194+ if last != p.failed && (!p.ext || last != 0) {
193 t.Fatalf("refused capture not failed: last event %d", last)195 t.Fatalf("refused capture not failed: last event %d", last)
194 }196 }
195 return ports.CaptureRequest{}, false197 return ports.CaptureRequest{}, false
@@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) {
287 }289 }
288 a.destroy(t, p)290 a.destroy(t, p)
289 h.waitInflight(t, maxCaptureInflight)291 h.waitInflight(t, maxCaptureInflight)
290- if _, ok := h.accepted(t, a.captureExt(t)); ok {292+ p = a.captureExt(t)
293+ if _, ok := h.accepted(t, p); ok {
291 t.Fatal("cap exceeded after ext refill")294 t.Fatal("cap exceeded after ext refill")
292 }295 }
296+ // A live refused frame would retry into the freed credit: drop it.
297+ a.destroy(t, p)
293 h.complete(t, req, maxCaptureInflight-1)298 h.complete(t, req, maxCaptureInflight-1)
294 for i, r := range reqs[1:] {299 for i, r := range reqs[1:] {
295 h.complete(t, r, maxCaptureInflight-2-i)300 h.complete(t, r, maxCaptureInflight-2-i)
M internal/adapters/wayland/imagecapture_test.go +55 -8
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "context"
5+ "errors"
56 "image"
67 "strings"
78 "sync/atomic"
@@ -316,6 +317,22 @@ func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEv
316317 return ev
317318 }
318319
320+// liveExtFrame is extFrame whose frame object stays alive, so a held frame
321+// can still be served.
322+func (cc *cclient) liveExtFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
323+ t.Helper()
324+ buf, _ := captureShm(t, cc.c, w, h)
325+ frame := cc.c.AllocateID()
326+ ev := &captureEvents{events: make(chan uint16, 16)}
327+ ev.SetID(frame)
328+ registerWireProxy(cc.c, ev)
329+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
330+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
331+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
332+ cc.roundtrip(t)
333+ return ev
334+}
335+
319336 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
320337 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
321338 t.Helper()
@@ -947,21 +964,21 @@ func TestExclusionDestroyFreesTheSlot(t *testing.T) {
947964 }
948965 }
949966
950-// Frames of the session that owns the exclusion are fenced: refused until
951-// core knows the exclusion, then tagged with its revision. Every other
952-// capture, wlr-screencopy included, carries no tag.
967+// Frames of the session that owns the exclusion are fenced: held until core
968+// knows the exclusion, then tagged with its revision. Every other capture,
969+// wlr-screencopy included, carries no tag.
953970 func TestExclusionFrameTagging(t *testing.T) {
954971 h := newCaptureHarness(t)
955972 owner, session, open, _, _ := h.exclusionOwner(t)
956- // Core has not confirmed the exclusion: the frame fails.
957- ev := owner.extFrame(t, session, 4, 4)
958- if !eventsInclude(ev, frameFailed) {
959- t.Fatal("a frame before core confirmed the exclusion was served")
973+ // Core has not confirmed the exclusion: the frame waits, unserved.
974+ ev := owner.liveExtFrame(t, session, 4, 4)
975+ if eventsInclude(ev, frameFailed) || eventsInclude(ev, frameReady) {
976+ t.Fatal("a frame before core confirmed the exclusion was answered")
960977 }
961978 noCapture(t, h.captures)
979+ // Once core confirms it, the held frame is served, tagged.
962980 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
963981 h.settle(t, owner)
964- owner.extFrame(t, session, 4, 4)
965982 r := receiveCapture(t, h.captures)
966983 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
967984 t.Fatalf("owner frame %+v", r)
@@ -986,6 +1003,36 @@ func TestExclusionFrameTagging(t *testing.T) {
9861003 }
9871004 }
9881005
1006+// A renderer failure is transient for the client: the frame is asked again
1007+// and served, it does not fail (which ends a portal screencast).
1008+func TestExtFrameRetriesTransientFailure(t *testing.T) {
1009+ h := newCaptureHarness(t)
1010+ cc := h.client(t)
1011+ session, ev := cc.session(t, cc.outputSource(t))
1012+ constraints(t, cc, ev)
1013+ open := h.open(t)
1014+ h.send(active(open.ID))
1015+ h.settle(t, cc)
1016+ frame := cc.liveExtFrame(t, session, 4, 4)
1017+ r := receiveCapture(t, h.captures)
1018+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: errors.New("capture indicator is not on screen")}
1019+ r = receiveCapture(t, h.captures)
1020+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1021+ for deadline := time.Now().Add(2 * time.Second); ; {
1022+ cc.roundtrip(t)
1023+ if len(frame.events) > 0 {
1024+ break
1025+ }
1026+ if time.Now().After(deadline) {
1027+ t.Fatal("retried frame not answered")
1028+ }
1029+ time.Sleep(time.Millisecond)
1030+ }
1031+ if eventsInclude(frame, frameFailed) {
1032+ t.Fatal("transient failure ended the frame")
1033+ }
1034+}
1035+
9891036 // hud is a second connection: a layer surface it attaches with the token.
9901037 type hud struct {
9911038 *cclient
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "image"6 "image"
6 "strings"7 "strings"
7 "sync/atomic"8 "sync/atomic"
@@ -316,6 +317,22 @@ func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEv
316 return ev317 return ev
317 }318 }
318 319
320+// liveExtFrame is extFrame whose frame object stays alive, so a held frame
321+// can still be served.
322+func (cc *cclient) liveExtFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
323+ t.Helper()
324+ buf, _ := captureShm(t, cc.c, w, h)
325+ frame := cc.c.AllocateID()
326+ ev := &captureEvents{events: make(chan uint16, 16)}
327+ ev.SetID(frame)
328+ registerWireProxy(cc.c, ev)
329+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
330+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
331+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
332+ cc.roundtrip(t)
333+ return ev
334+}
335+
319 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.336 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
320 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {337 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
321 t.Helper()338 t.Helper()
@@ -947,21 +964,21 @@ func TestExclusionDestroyFreesTheSlot(t *testing.T) {
947 }964 }
948 }965 }
949 966
950-// Frames of the session that owns the exclusion are fenced: refused until967+// Frames of the session that owns the exclusion are fenced: held until core
951-// core knows the exclusion, then tagged with its revision. Every other968+// knows the exclusion, then tagged with its revision. Every other capture,
952-// capture, wlr-screencopy included, carries no tag.969+// wlr-screencopy included, carries no tag.
953 func TestExclusionFrameTagging(t *testing.T) {970 func TestExclusionFrameTagging(t *testing.T) {
954 h := newCaptureHarness(t)971 h := newCaptureHarness(t)
955 owner, session, open, _, _ := h.exclusionOwner(t)972 owner, session, open, _, _ := h.exclusionOwner(t)
956- // Core has not confirmed the exclusion: the frame fails.973+ // Core has not confirmed the exclusion: the frame waits, unserved.
957- ev := owner.extFrame(t, session, 4, 4)974+ ev := owner.liveExtFrame(t, session, 4, 4)
958- if !eventsInclude(ev, frameFailed) {975+ if eventsInclude(ev, frameFailed) || eventsInclude(ev, frameReady) {
959- t.Fatal("a frame before core confirmed the exclusion was served")976+ t.Fatal("a frame before core confirmed the exclusion was answered")
960 }977 }
961 noCapture(t, h.captures)978 noCapture(t, h.captures)
979+ // Once core confirms it, the held frame is served, tagged.
962 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})980 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
963 h.settle(t, owner)981 h.settle(t, owner)
964- owner.extFrame(t, session, 4, 4)
965 r := receiveCapture(t, h.captures)982 r := receiveCapture(t, h.captures)
966 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {983 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
967 t.Fatalf("owner frame %+v", r)984 t.Fatalf("owner frame %+v", r)
@@ -986,6 +1003,36 @@ func TestExclusionFrameTagging(t *testing.T) {
986 }1003 }
987 }1004 }
988 1005
1006+// A renderer failure is transient for the client: the frame is asked again
1007+// and served, it does not fail (which ends a portal screencast).
1008+func TestExtFrameRetriesTransientFailure(t *testing.T) {
1009+ h := newCaptureHarness(t)
1010+ cc := h.client(t)
1011+ session, ev := cc.session(t, cc.outputSource(t))
1012+ constraints(t, cc, ev)
1013+ open := h.open(t)
1014+ h.send(active(open.ID))
1015+ h.settle(t, cc)
1016+ frame := cc.liveExtFrame(t, session, 4, 4)
1017+ r := receiveCapture(t, h.captures)
1018+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: errors.New("capture indicator is not on screen")}
1019+ r = receiveCapture(t, h.captures)
1020+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1021+ for deadline := time.Now().Add(2 * time.Second); ; {
1022+ cc.roundtrip(t)
1023+ if len(frame.events) > 0 {
1024+ break
1025+ }
1026+ if time.Now().After(deadline) {
1027+ t.Fatal("retried frame not answered")
1028+ }
1029+ time.Sleep(time.Millisecond)
1030+ }
1031+ if eventsInclude(frame, frameFailed) {
1032+ t.Fatal("transient failure ended the frame")
1033+ }
1034+}
1035+
989 // hud is a second connection: a layer surface it attaches with the token.1036 // hud is a second connection: a layer surface it attaches with the token.
990 type hud struct {1037 type hud struct {
991 *cclient1038 *cclient
M internal/adapters/wayland/imagecopy.go +48 -6
@@ -3,6 +3,7 @@ package wayland
33 import (
44 "image"
55 "math"
6+ "time"
67
78 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
89 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -441,8 +442,20 @@ type captureExtFrame struct {
441442 buf *wayland.Buffer
442443 used bool
443444 replyID uint64
445+ // deadline ends the retries of a frame whose target is not ready yet.
446+ deadline time.Time
444447 }
445448
449+// A frame whose target is briefly unavailable (not active yet, rendered off
450+// screen by another session, its indicator or exclusion not on screen yet,
451+// a renderer slot busy) is tried again rather than failed: failed(unknown)
452+// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only
453+// a stopped session, a refused client or a wrong buffer fail at once.
454+const (
455+ captureRetryEvery = 16 * time.Millisecond
456+ captureRetryFor = time.Second
457+)
458+
446459 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
447460 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
448461 if f.used {
@@ -488,9 +501,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
488501 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
489502 return
490503 }
504+ f.deadline = c.s.clock.Now().Add(captureRetryFor)
505+ f.attempt()
506+}
507+
508+// retry tries the frame again shortly, or fails it past its deadline.
509+func (f *captureExtFrame) retry(why string) {
510+ s := f.session.s
511+ if !s.clock.Now().Before(f.deadline) {
512+ s.log.Debug().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries")
513+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
514+ return
515+ }
516+ s.clock.AfterFunc(captureRetryEvery, func() { s.display.Do(f.attempt) })
517+}
518+
519+// attempt serves the frame from the current target. Display goroutine only.
520+func (f *captureExtFrame) attempt() {
521+ if !f.res.Alive() {
522+ return
523+ }
524+ c := f.session
525+ if c.stopped || !c.s.mayCaptureFrame(f.res.Client()) {
526+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
527+ return
528+ }
491529 o, region, hidden, ok := c.geometry()
492530 if !ok || c.haveState && !c.st.Active {
493- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
531+ f.retry("target not ready")
494532 return
495533 }
496534 tag, ok := c.tag(hidden)
@@ -501,12 +539,12 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
501539 return
502540 case !ok:
503541 // The exclusion is not confirmed by core yet: nothing is served.
504- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
542+ f.retry("exclusion not confirmed")
505543 return
506544 }
507545 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
508546 if done.Err != nil {
509- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
547+ f.retry(done.Err.Error())
510548 return
511549 }
512550 f.res.SendTransform(0)
@@ -516,10 +554,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
516554 f.res.SendReady()
517555 }, f.res.Resource, tag)
518556 if !ok {
519- if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
557+ switch {
558+ case !c.s.validCaptureBuffer(f.buf, region, buf.format):
520559 f.constraintsFailed(region)
521- } else {
522- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
560+ case !c.s.mayCaptureFrame(f.res.Client()):
561+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
562+ default:
563+ // The renderer queue or the in-flight bound is full.
564+ f.retry("capture queue full")
523565 }
524566 }
525567 }
@@ -3,6 +3,7 @@ package wayland
3 import (3 import (
4 "image"4 "image"
5 "math"5 "math"
6+ "time"
6 7
7 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"8 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
8 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"9 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -441,8 +442,20 @@ type captureExtFrame struct {
441 buf *wayland.Buffer442 buf *wayland.Buffer
442 used bool443 used bool
443 replyID uint64444 replyID uint64
445+ // deadline ends the retries of a frame whose target is not ready yet.
446+ deadline time.Time
444 }447 }
445 448
449+// A frame whose target is briefly unavailable (not active yet, rendered off
450+// screen by another session, its indicator or exclusion not on screen yet,
451+// a renderer slot busy) is tried again rather than failed: failed(unknown)
452+// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only
453+// a stopped session, a refused client or a wrong buffer fail at once.
454+const (
455+ captureRetryEvery = 16 * time.Millisecond
456+ captureRetryFor = time.Second
457+)
458+
446 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}459 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
447 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {460 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
448 if f.used {461 if f.used {
@@ -488,9 +501,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
488 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")501 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
489 return502 return
490 }503 }
504+ f.deadline = c.s.clock.Now().Add(captureRetryFor)
505+ f.attempt()
506+}
507+
508+// retry tries the frame again shortly, or fails it past its deadline.
509+func (f *captureExtFrame) retry(why string) {
510+ s := f.session.s
511+ if !s.clock.Now().Before(f.deadline) {
512+ s.log.Debug().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries")
513+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
514+ return
515+ }
516+ s.clock.AfterFunc(captureRetryEvery, func() { s.display.Do(f.attempt) })
517+}
518+
519+// attempt serves the frame from the current target. Display goroutine only.
520+func (f *captureExtFrame) attempt() {
521+ if !f.res.Alive() {
522+ return
523+ }
524+ c := f.session
525+ if c.stopped || !c.s.mayCaptureFrame(f.res.Client()) {
526+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
527+ return
528+ }
491 o, region, hidden, ok := c.geometry()529 o, region, hidden, ok := c.geometry()
492 if !ok || c.haveState && !c.st.Active {530 if !ok || c.haveState && !c.st.Active {
493- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))531+ f.retry("target not ready")
494 return532 return
495 }533 }
496 tag, ok := c.tag(hidden)534 tag, ok := c.tag(hidden)
@@ -501,12 +539,12 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
501 return539 return
502 case !ok:540 case !ok:
503 // The exclusion is not confirmed by core yet: nothing is served.541 // The exclusion is not confirmed by core yet: nothing is served.
504- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))542+ f.retry("exclusion not confirmed")
505 return543 return
506 }544 }
507 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {545 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
508 if done.Err != nil {546 if done.Err != nil {
509- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))547+ f.retry(done.Err.Error())
510 return548 return
511 }549 }
512 f.res.SendTransform(0)550 f.res.SendTransform(0)
@@ -516,10 +554,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
516 f.res.SendReady()554 f.res.SendReady()
517 }, f.res.Resource, tag)555 }, f.res.Resource, tag)
518 if !ok {556 if !ok {
519- if !c.s.validCaptureBuffer(f.buf, region, buf.format) {557+ switch {
558+ case !c.s.validCaptureBuffer(f.buf, region, buf.format):
520 f.constraintsFailed(region)559 f.constraintsFailed(region)
521- } else {560+ case !c.s.mayCaptureFrame(f.res.Client()):
522- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))561+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
562+ default:
563+ // The renderer queue or the in-flight bound is full.
564+ f.retry("capture queue full")
523 }565 }
524 }566 }
525 }567 }
M internal/adapters/wayland/server.go +3 -3
@@ -170,12 +170,12 @@ type Server struct {
170170 readinessGeneration uint64
171171 lastFlip map[string]time.Time
172172 // tokens are the issued xdg-activation tokens (activation.go).
173- tokens map[string]activationToken
173+ tokens map[string]activationToken
174174 // foreignExports are the live xdg-foreign handles (foreign.go).
175175 foreignExports map[string]*foreignExport
176176 surfaces map[*server.Resource]*surface
177- buffers map[*server.Resource]clientBuffer
178- dmabuf *dmabufGlobal
177+ buffers map[*server.Resource]clientBuffer
178+ dmabuf *dmabufGlobal
179179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
180180 hdrOutputs map[string]ports.OutputHDR
181181 colorID uint64
@@ -170,12 +170,12 @@ type Server struct {
170 readinessGeneration uint64170 readinessGeneration uint64
171 lastFlip map[string]time.Time171 lastFlip map[string]time.Time
172 // tokens are the issued xdg-activation tokens (activation.go).172 // tokens are the issued xdg-activation tokens (activation.go).
173- tokens map[string]activationToken173+ tokens map[string]activationToken
174 // foreignExports are the live xdg-foreign handles (foreign.go).174 // foreignExports are the live xdg-foreign handles (foreign.go).
175 foreignExports map[string]*foreignExport175 foreignExports map[string]*foreignExport
176 surfaces map[*server.Resource]*surface176 surfaces map[*server.Resource]*surface
177- buffers map[*server.Resource]clientBuffer177+ buffers map[*server.Resource]clientBuffer
178- dmabuf *dmabufGlobal178+ dmabuf *dmabufGlobal
179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
180 hdrOutputs map[string]ports.OutputHDR180 hdrOutputs map[string]ports.OutputHDR
181 colorID uint64181 colorID uint64

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close