fix(capture): retry only transient capture failures
Mark the renderer, indicator and exclusion errors that may clear with ports.ErrCaptureTransient. An ext-image-copy-capture frame retries only those; a bad buffer or unsupported size fails at once. Transient errors log at debug, the final give-up at info. Test the retry deadline with a mock clock and document that portals are per user.
- commit
- fdbb95e859ff3b01c35aa6ec3a2116fe7b6715c3
- author
- brice · · 2026-10-03 08:32 UTC
- committer
- brice · · 2026-10-03 08:36 UTC
- parent
- 88fa6a5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- feat/xdg-foreign
10 files · +110 -31
M docs/headless.md +3 -3
| @@ -9,11 +9,11 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 9 | 9 | ||
| 10 | 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 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; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). 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. 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). | |
| 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, a stopped session or an error a retry cannot clear (a bad buffer) 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 (an ext-image-copy-capture frame then asks again, until its one-second deadline). 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 | ||
| 18 | 18 | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order. | |
| 19 | 19 | ||
| @@ -9,11 +9,11 @@ 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 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; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). 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. 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). | 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, a stopped session or an error a retry cannot clear (a bad buffer) 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 (an ext-image-copy-capture frame then asks again, until its one-second deadline). 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 | ||||
| 18 | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order. | 18 | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order. | ||
| 19 | 19 | ||||
M internal/adapters/capture/excluded.go +4 -4
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package capture | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 4 | + | "fmt" | |
| 5 | 5 | "os" | |
| 6 | 6 | "slices" | |
| 7 | 7 | ||
| @@ -16,17 +16,17 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // ErrSessionInactive fails an Exclude request whose session is not the one | |
| 18 | 18 | // the scene carries. Serving it as a normal capture would leak its HUD. | |
| 19 | - | var ErrSessionInactive = errors.New("capture exclusion is not active") | |
| 19 | + | var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports.ErrCaptureTransient) | |
| 20 | 20 | ||
| 21 | 21 | // ErrSessionStale fails an Exclude request stamped with a revision newer than | |
| 22 | 22 | // the scene's: the scene predates a change of what an excluded frame must | |
| 23 | 23 | // hide (a new HUD layer, popup, target), so its exclusion list could leak. | |
| 24 | - | var ErrSessionStale = errors.New("capture exclusion scene is older than the request") | |
| 24 | + | var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient) | |
| 25 | 25 | ||
| 26 | 26 | // ErrWorkspaceMoved fails a workspace request whose workspace is neither on | |
| 27 | 27 | // screen nor the one rendered off screen in the scene: it moved, went away, | |
| 28 | 28 | // or the scene predates the session. | |
| 29 | - | var ErrWorkspaceMoved = errors.New("captured workspace is not where the request expected") | |
| 29 | + | var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient) | |
| 30 | 30 | ||
| 31 | 31 | // excluder derives the scene of Exclude captures: the frame without the | |
| 32 | 32 | // excluded layers and popups. Its scratch slices are reused, | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package capture | 1 | package capture | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "errors" | 4 | + | "fmt" |
| 5 | "os" | 5 | "os" | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | 7 | ||||
| @@ -16,17 +16,17 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // ErrSessionInactive fails an Exclude request whose session is not the one | 17 | // ErrSessionInactive fails an Exclude request whose session is not the one | ||
| 18 | // the scene carries. Serving it as a normal capture would leak its HUD. | 18 | // the scene carries. Serving it as a normal capture would leak its HUD. | ||
| 19 | - | var ErrSessionInactive = errors.New("capture exclusion is not active") | 19 | + | var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports.ErrCaptureTransient) |
| 20 | 20 | ||||
| 21 | // ErrSessionStale fails an Exclude request stamped with a revision newer than | 21 | // ErrSessionStale fails an Exclude request stamped with a revision newer than | ||
| 22 | // the scene's: the scene predates a change of what an excluded frame must | 22 | // the scene's: the scene predates a change of what an excluded frame must | ||
| 23 | // hide (a new HUD layer, popup, target), so its exclusion list could leak. | 23 | // hide (a new HUD layer, popup, target), so its exclusion list could leak. | ||
| 24 | - | var ErrSessionStale = errors.New("capture exclusion scene is older than the request") | 24 | + | var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient) |
| 25 | 25 | ||||
| 26 | // ErrWorkspaceMoved fails a workspace request whose workspace is neither on | 26 | // ErrWorkspaceMoved fails a workspace request whose workspace is neither on | ||
| 27 | // screen nor the one rendered off screen in the scene: it moved, went away, | 27 | // screen nor the one rendered off screen in the scene: it moved, went away, | ||
| 28 | // or the scene predates the session. | 28 | // or the scene predates the session. | ||
| 29 | - | var ErrWorkspaceMoved = errors.New("captured workspace is not where the request expected") | 29 | + | var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient) |
| 30 | 30 | ||||
| 31 | // excluder derives the scene of Exclude captures: the frame without the | 31 | // excluder derives the scene of Exclude captures: the frame without the | ||
| 32 | // excluded layers and popups. Its scratch slices are reused, | 32 | // excluded layers and popups. Its scratch slices are reused, | ||
M internal/adapters/capture/fence.go +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package capture | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 4 | + | "fmt" | |
| 5 | 5 | "image" | |
| 6 | 6 | "math" | |
| 7 | 7 | "time" | |
| @@ -46,7 +46,7 @@ const HoldFor = 250 * time.Millisecond | |||
| 46 | 46 | ||
| 47 | 47 | // ErrIndicatorMissing fails a request whose indicator was not on screen in | |
| 48 | 48 | // time. | |
| 49 | - | var ErrIndicatorMissing = errors.New("capture indicator is not on screen") | |
| 49 | + | var ErrIndicatorMissing = fmt.Errorf("%w: capture indicator is not on screen", ports.ErrCaptureTransient) | |
| 50 | 50 | ||
| 51 | 51 | // IndicatorShown reports whether scene s shows the indicator request q needs. | |
| 52 | 52 | // A request that does not ask for one (Indicate false) needs none. | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package capture | 1 | package capture | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "errors" | 4 | + | "fmt" |
| 5 | "image" | 5 | "image" | ||
| 6 | "math" | 6 | "math" | ||
| 7 | "time" | 7 | "time" | ||
| @@ -46,7 +46,7 @@ const HoldFor = 250 * time.Millisecond | |||||
| 46 | 46 | ||||
| 47 | // ErrIndicatorMissing fails a request whose indicator was not on screen in | 47 | // ErrIndicatorMissing fails a request whose indicator was not on screen in | ||
| 48 | // time. | 48 | // time. | ||
| 49 | - | var ErrIndicatorMissing = errors.New("capture indicator is not on screen") | 49 | + | var ErrIndicatorMissing = fmt.Errorf("%w: capture indicator is not on screen", ports.ErrCaptureTransient) |
| 50 | 50 | ||||
| 51 | // IndicatorShown reports whether scene s shows the indicator request q needs. | 51 | // IndicatorShown reports whether scene s shows the indicator request q needs. | ||
| 52 | // A request that does not ask for one (Indicate false) needs none. | 52 | // A request that does not ask for one (Indicate false) needs none. | ||
M internal/adapters/capture/offscreen.go +4 -2
| @@ -41,7 +41,7 @@ var ( | |||
| 41 | 41 | ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported") | |
| 42 | 42 | // ErrOffscreenBusy fails a request while the child is still busy with | |
| 43 | 43 | // another session or size, or has too many renders in flight. | |
| 44 | - | ErrOffscreenBusy = errors.New("workspace capture is busy") | |
| 44 | + | ErrOffscreenBusy = fmt.Errorf("%w: workspace capture is busy", ports.ErrCaptureTransient) | |
| 45 | 45 | ) | |
| 46 | 46 | ||
| 47 | 47 | // fenceSlot is one child render whose GPU reads may not be finished: its sync | |
| @@ -397,7 +397,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 397 | 397 | k := 0 | |
| 398 | 398 | for _, q := range reqs { | |
| 399 | 399 | if q.Region.Dx() != w || q.Region.Dy() != h { | |
| 400 | - | Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies) | |
| 400 | + | // A resize the request has not caught up with: transient, the | |
| 401 | + | // next try uses the new size or new buffer constraints. | |
| 402 | + | Fail(p.ctx, q, fmt.Errorf("%w: %w: region %v is not the whole %dx%d workspace frame", ports.ErrCaptureTransient, ErrOffscreenGeometry, q.Region, w, h), p.replies) | |
| 401 | 403 | continue | |
| 402 | 404 | } | |
| 403 | 405 | q.Region = image.Rect(0, 0, w, h) | |
| @@ -41,7 +41,7 @@ var ( | |||||
| 41 | ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported") | 41 | ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported") | ||
| 42 | // ErrOffscreenBusy fails a request while the child is still busy with | 42 | // ErrOffscreenBusy fails a request while the child is still busy with | ||
| 43 | // another session or size, or has too many renders in flight. | 43 | // another session or size, or has too many renders in flight. | ||
| 44 | - | ErrOffscreenBusy = errors.New("workspace capture is busy") | 44 | + | ErrOffscreenBusy = fmt.Errorf("%w: workspace capture is busy", ports.ErrCaptureTransient) |
| 45 | ) | 45 | ) | ||
| 46 | 46 | ||||
| 47 | // fenceSlot is one child render whose GPU reads may not be finished: its sync | 47 | // fenceSlot is one child render whose GPU reads may not be finished: its sync | ||
| @@ -397,7 +397,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||||
| 397 | k := 0 | 397 | k := 0 | ||
| 398 | for _, q := range reqs { | 398 | for _, q := range reqs { | ||
| 399 | if q.Region.Dx() != w || q.Region.Dy() != h { | 399 | if q.Region.Dx() != w || q.Region.Dy() != h { | ||
| 400 | - | Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies) | 400 | + | // A resize the request has not caught up with: transient, the |
| 401 | + | // next try uses the new size or new buffer constraints. | |||
| 402 | + | Fail(p.ctx, q, fmt.Errorf("%w: %w: region %v is not the whole %dx%d workspace frame", ports.ErrCaptureTransient, ErrOffscreenGeometry, q.Region, w, h), p.replies) | |||
| 401 | continue | 403 | continue | ||
| 402 | } | 404 | } | ||
| 403 | q.Region = image.Rect(0, 0, w, h) | 405 | q.Region = image.Rect(0, 0, w, h) | ||
M internal/adapters/capture/pipeline.go +1 -1
| @@ -142,7 +142,7 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req | |||
| 142 | 142 | } | |
| 143 | 143 | var err error | |
| 144 | 144 | if b == nil { | |
| 145 | - | err = fmt.Errorf("capture pipeline full or stopped") | |
| 145 | + | err = fmt.Errorf("%w: capture pipeline full or stopped", ports.ErrCaptureTransient) | |
| 146 | 146 | } else if !p.captureAllowed(state) { | |
| 147 | 147 | err = ErrSecurityState | |
| 148 | 148 | } else { | |
| @@ -142,7 +142,7 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req | |||||
| 142 | } | 142 | } | ||
| 143 | var err error | 143 | var err error | ||
| 144 | if b == nil { | 144 | if b == nil { | ||
| 145 | - | err = fmt.Errorf("capture pipeline full or stopped") | 145 | + | err = fmt.Errorf("%w: capture pipeline full or stopped", ports.ErrCaptureTransient) |
| 146 | } else if !p.captureAllowed(state) { | 146 | } else if !p.captureAllowed(state) { | ||
| 147 | err = ErrSecurityState | 147 | err = ErrSecurityState | ||
| 148 | } else { | 148 | } else { | ||
M internal/adapters/launcher/session.go +2 -0
| @@ -16,6 +16,8 @@ var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", | |||
| 16 | 16 | // portalUnits are the desktop portal and its backends. They read the | |
| 17 | 17 | // session variables once, at start: a portal started without them (before | |
| 18 | 18 | // the export, or after the last session ended) never serves this session. | |
| 19 | + | // Portals are per user, like the exported variables: another graphical | |
| 20 | + | // session of the same user running at once is not supported. | |
| 19 | 21 | const portalUnits = "xdg-desktop-portal*.service" | |
| 20 | 22 | ||
| 21 | 23 | // ExportSession shares the session's displays with D-Bus and systemd user | |
| @@ -16,6 +16,8 @@ var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", | |||||
| 16 | // portalUnits are the desktop portal and its backends. They read the | 16 | // portalUnits are the desktop portal and its backends. They read the | ||
| 17 | // session variables once, at start: a portal started without them (before | 17 | // session variables once, at start: a portal started without them (before | ||
| 18 | // the export, or after the last session ended) never serves this session. | 18 | // the export, or after the last session ended) never serves this session. | ||
| 19 | + | // Portals are per user, like the exported variables: another graphical | |||
| 20 | + | // session of the same user running at once is not supported. | |||
| 19 | const portalUnits = "xdg-desktop-portal*.service" | 21 | const portalUnits = "xdg-desktop-portal*.service" | ||
| 20 | 22 | ||||
| 21 | // ExportSession shares the session's displays with D-Bus and systemd user | 23 | // ExportSession shares the session's displays with D-Bus and systemd user | ||
M internal/adapters/wayland/imagecapture_test.go +75 -14
| @@ -3,6 +3,7 @@ package wayland | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "image" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync/atomic" | |
| @@ -17,9 +18,11 @@ import ( | |||
| 17 | 18 | wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| 19 | 20 | "github.com/bnema/neferwl/internal/logging" | |
| 21 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 20 | 22 | "github.com/bnema/neferwl/internal/ports" | |
| 21 | 23 | "github.com/bnema/purego-libwayland/server" | |
| 22 | 24 | "github.com/bnema/wlturbo" | |
| 25 | + | "github.com/stretchr/testify/mock" | |
| 23 | 26 | "golang.org/x/sys/unix" | |
| 24 | 27 | ) | |
| 25 | 28 | ||
| @@ -1003,10 +1006,60 @@ func TestExclusionFrameTagging(t *testing.T) { | |||
| 1003 | 1006 | } | |
| 1004 | 1007 | } | |
| 1005 | 1008 | ||
| 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) | |
| 1009 | + | // A transient renderer failure is asked again and served: failing the frame | |
| 1010 | + | // would end a portal screencast. A permanent one fails at once, unretried. | |
| 1011 | + | func TestExtFrameRendererFailure(t *testing.T) { | |
| 1012 | + | for _, tc := range []struct { | |
| 1013 | + | name string | |
| 1014 | + | err error | |
| 1015 | + | retry bool | |
| 1016 | + | }{ | |
| 1017 | + | {"transient", fmt.Errorf("%w: indicator", ports.ErrCaptureTransient), true}, | |
| 1018 | + | {"permanent", errors.New("capture buffer truncated"), false}, | |
| 1019 | + | } { | |
| 1020 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1021 | + | h := newCaptureHarness(t) | |
| 1022 | + | cc := h.client(t) | |
| 1023 | + | session, ev := cc.session(t, cc.outputSource(t)) | |
| 1024 | + | constraints(t, cc, ev) | |
| 1025 | + | open := h.open(t) | |
| 1026 | + | h.send(active(open.ID)) | |
| 1027 | + | h.settle(t, cc) | |
| 1028 | + | frame := cc.liveExtFrame(t, session, 4, 4) | |
| 1029 | + | r := receiveCapture(t, h.captures) | |
| 1030 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: tc.err} | |
| 1031 | + | if tc.retry { | |
| 1032 | + | r = receiveCapture(t, h.captures) | |
| 1033 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | |
| 1034 | + | } | |
| 1035 | + | for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) { | |
| 1036 | + | cc.roundtrip(t) | |
| 1037 | + | if time.Now().After(deadline) { | |
| 1038 | + | t.Fatal("frame not answered") | |
| 1039 | + | } | |
| 1040 | + | } | |
| 1041 | + | if failed := eventsInclude(frame, frameFailed); failed == tc.retry { | |
| 1042 | + | t.Fatalf("failed = %v", failed) | |
| 1043 | + | } | |
| 1044 | + | if !tc.retry { | |
| 1045 | + | noCapture(t, h.captures) | |
| 1046 | + | } | |
| 1047 | + | }) | |
| 1048 | + | } | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | // A frame whose target stays unavailable fails once its retry deadline has | |
| 1052 | + | // passed, so a client is never left waiting. | |
| 1053 | + | func TestExtFrameRetryDeadline(t *testing.T) { | |
| 1054 | + | var now atomic.Int64 | |
| 1055 | + | timers := make(chan func(), 4) | |
| 1056 | + | clock := portsmocks.NewMockClock(t) | |
| 1057 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(0, now.Load()) }).Maybe() | |
| 1058 | + | clock.EXPECT().AfterFunc(mock.Anything, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer { | |
| 1059 | + | timers <- f | |
| 1060 | + | return portsmocks.NewMockTimer(t) | |
| 1061 | + | }).Maybe() | |
| 1062 | + | h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) { o.Clock = clock }) | |
| 1010 | 1063 | cc := h.client(t) | |
| 1011 | 1064 | session, ev := cc.session(t, cc.outputSource(t)) | |
| 1012 | 1065 | constraints(t, cc, ev) | |
| @@ -1014,22 +1067,30 @@ func TestExtFrameRetriesTransientFailure(t *testing.T) { | |||
| 1014 | 1067 | h.send(active(open.ID)) | |
| 1015 | 1068 | h.settle(t, cc) | |
| 1016 | 1069 | frame := cc.liveExtFrame(t, session, 4, 4) | |
| 1070 | + | busy := fmt.Errorf("%w: busy", ports.ErrCaptureTransient) | |
| 1017 | 1071 | 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")} | |
| 1072 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy} | |
| 1073 | + | var fire func() | |
| 1074 | + | select { | |
| 1075 | + | case fire = <-timers: | |
| 1076 | + | case <-time.After(2 * time.Second): | |
| 1077 | + | t.Fatal("no retry armed") | |
| 1078 | + | } | |
| 1079 | + | now.Store(int64(captureRetryFor)) | |
| 1080 | + | fire() | |
| 1019 | 1081 | 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); ; { | |
| 1082 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy} | |
| 1083 | + | for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) { | |
| 1022 | 1084 | cc.roundtrip(t) | |
| 1023 | - | if len(frame.events) > 0 { | |
| 1024 | - | break | |
| 1025 | - | } | |
| 1026 | 1085 | if time.Now().After(deadline) { | |
| 1027 | - | t.Fatal("retried frame not answered") | |
| 1086 | + | t.Fatal("frame not failed past its deadline") | |
| 1028 | 1087 | } | |
| 1029 | - | time.Sleep(time.Millisecond) | |
| 1030 | 1088 | } | |
| 1031 | - | if eventsInclude(frame, frameFailed) { | |
| 1032 | - | t.Fatal("transient failure ended the frame") | |
| 1089 | + | if !eventsInclude(frame, frameFailed) { | |
| 1090 | + | t.Fatal("frame not failed past its deadline") | |
| 1091 | + | } | |
| 1092 | + | if len(timers) > 0 { | |
| 1093 | + | t.Fatal("retried past its deadline") | |
| 1033 | 1094 | } | |
| 1034 | 1095 | } | |
| 1035 | 1096 | ||
| @@ -3,6 +3,7 @@ package wayland | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "image" | 7 | "image" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| @@ -17,9 +18,11 @@ import ( | |||||
| 17 | wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | 18 | wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | ||
| 18 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | 19 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | ||
| 19 | "github.com/bnema/neferwl/internal/logging" | 20 | "github.com/bnema/neferwl/internal/logging" | ||
| 21 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 20 | "github.com/bnema/neferwl/internal/ports" | 22 | "github.com/bnema/neferwl/internal/ports" | ||
| 21 | "github.com/bnema/purego-libwayland/server" | 23 | "github.com/bnema/purego-libwayland/server" | ||
| 22 | "github.com/bnema/wlturbo" | 24 | "github.com/bnema/wlturbo" | ||
| 25 | + | "github.com/stretchr/testify/mock" | |||
| 23 | "golang.org/x/sys/unix" | 26 | "golang.org/x/sys/unix" | ||
| 24 | ) | 27 | ) | ||
| 25 | 28 | ||||
| @@ -1003,10 +1006,60 @@ func TestExclusionFrameTagging(t *testing.T) { | |||||
| 1003 | } | 1006 | } | ||
| 1004 | } | 1007 | } | ||
| 1005 | 1008 | ||||
| 1006 | - | // A renderer failure is transient for the client: the frame is asked again | 1009 | + | // A transient renderer failure is asked again and served: failing the frame |
| 1007 | - | // and served, it does not fail (which ends a portal screencast). | 1010 | + | // would end a portal screencast. A permanent one fails at once, unretried. |
| 1008 | - | func TestExtFrameRetriesTransientFailure(t *testing.T) { | 1011 | + | func TestExtFrameRendererFailure(t *testing.T) { |
| 1009 | - | h := newCaptureHarness(t) | 1012 | + | for _, tc := range []struct { |
| 1013 | + | name string | |||
| 1014 | + | err error | |||
| 1015 | + | retry bool | |||
| 1016 | + | }{ | |||
| 1017 | + | {"transient", fmt.Errorf("%w: indicator", ports.ErrCaptureTransient), true}, | |||
| 1018 | + | {"permanent", errors.New("capture buffer truncated"), false}, | |||
| 1019 | + | } { | |||
| 1020 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1021 | + | h := newCaptureHarness(t) | |||
| 1022 | + | cc := h.client(t) | |||
| 1023 | + | session, ev := cc.session(t, cc.outputSource(t)) | |||
| 1024 | + | constraints(t, cc, ev) | |||
| 1025 | + | open := h.open(t) | |||
| 1026 | + | h.send(active(open.ID)) | |||
| 1027 | + | h.settle(t, cc) | |||
| 1028 | + | frame := cc.liveExtFrame(t, session, 4, 4) | |||
| 1029 | + | r := receiveCapture(t, h.captures) | |||
| 1030 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: tc.err} | |||
| 1031 | + | if tc.retry { | |||
| 1032 | + | r = receiveCapture(t, h.captures) | |||
| 1033 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | |||
| 1034 | + | } | |||
| 1035 | + | for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) { | |||
| 1036 | + | cc.roundtrip(t) | |||
| 1037 | + | if time.Now().After(deadline) { | |||
| 1038 | + | t.Fatal("frame not answered") | |||
| 1039 | + | } | |||
| 1040 | + | } | |||
| 1041 | + | if failed := eventsInclude(frame, frameFailed); failed == tc.retry { | |||
| 1042 | + | t.Fatalf("failed = %v", failed) | |||
| 1043 | + | } | |||
| 1044 | + | if !tc.retry { | |||
| 1045 | + | noCapture(t, h.captures) | |||
| 1046 | + | } | |||
| 1047 | + | }) | |||
| 1048 | + | } | |||
| 1049 | + | } | |||
| 1050 | + | ||||
| 1051 | + | // A frame whose target stays unavailable fails once its retry deadline has | |||
| 1052 | + | // passed, so a client is never left waiting. | |||
| 1053 | + | func TestExtFrameRetryDeadline(t *testing.T) { | |||
| 1054 | + | var now atomic.Int64 | |||
| 1055 | + | timers := make(chan func(), 4) | |||
| 1056 | + | clock := portsmocks.NewMockClock(t) | |||
| 1057 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(0, now.Load()) }).Maybe() | |||
| 1058 | + | clock.EXPECT().AfterFunc(mock.Anything, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer { | |||
| 1059 | + | timers <- f | |||
| 1060 | + | return portsmocks.NewMockTimer(t) | |||
| 1061 | + | }).Maybe() | |||
| 1062 | + | h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) { o.Clock = clock }) | |||
| 1010 | cc := h.client(t) | 1063 | cc := h.client(t) | ||
| 1011 | session, ev := cc.session(t, cc.outputSource(t)) | 1064 | session, ev := cc.session(t, cc.outputSource(t)) | ||
| 1012 | constraints(t, cc, ev) | 1065 | constraints(t, cc, ev) | ||
| @@ -1014,22 +1067,30 @@ func TestExtFrameRetriesTransientFailure(t *testing.T) { | |||||
| 1014 | h.send(active(open.ID)) | 1067 | h.send(active(open.ID)) | ||
| 1015 | h.settle(t, cc) | 1068 | h.settle(t, cc) | ||
| 1016 | frame := cc.liveExtFrame(t, session, 4, 4) | 1069 | frame := cc.liveExtFrame(t, session, 4, 4) | ||
| 1070 | + | busy := fmt.Errorf("%w: busy", ports.ErrCaptureTransient) | |||
| 1017 | r := receiveCapture(t, h.captures) | 1071 | 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")} | 1072 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy} |
| 1073 | + | var fire func() | |||
| 1074 | + | select { | |||
| 1075 | + | case fire = <-timers: | |||
| 1076 | + | case <-time.After(2 * time.Second): | |||
| 1077 | + | t.Fatal("no retry armed") | |||
| 1078 | + | } | |||
| 1079 | + | now.Store(int64(captureRetryFor)) | |||
| 1080 | + | fire() | |||
| 1019 | r = receiveCapture(t, h.captures) | 1081 | r = receiveCapture(t, h.captures) | ||
| 1020 | - | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | 1082 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy} |
| 1021 | - | for deadline := time.Now().Add(2 * time.Second); ; { | 1083 | + | for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) { |
| 1022 | cc.roundtrip(t) | 1084 | cc.roundtrip(t) | ||
| 1023 | - | if len(frame.events) > 0 { | |||
| 1024 | - | break | |||
| 1025 | - | } | |||
| 1026 | if time.Now().After(deadline) { | 1085 | if time.Now().After(deadline) { | ||
| 1027 | - | t.Fatal("retried frame not answered") | 1086 | + | t.Fatal("frame not failed past its deadline") |
| 1028 | } | 1087 | } | ||
| 1029 | - | time.Sleep(time.Millisecond) | |||
| 1030 | } | 1088 | } | ||
| 1031 | - | if eventsInclude(frame, frameFailed) { | 1089 | + | if !eventsInclude(frame, frameFailed) { |
| 1032 | - | t.Fatal("transient failure ended the frame") | 1090 | + | t.Fatal("frame not failed past its deadline") |
| 1091 | + | } | |||
| 1092 | + | if len(timers) > 0 { | |||
| 1093 | + | t.Fatal("retried past its deadline") | |||
| 1033 | } | 1094 | } | ||
| 1034 | } | 1095 | } | ||
| 1035 | 1096 | ||||
M internal/adapters/wayland/imagecopy.go +8 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "image" | |
| 5 | 6 | "math" | |
| 6 | 7 | "time" | |
| @@ -509,7 +510,7 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 509 | 510 | func (f *captureExtFrame) retry(why string) { | |
| 510 | 511 | s := f.session.s | |
| 511 | 512 | 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 | + | s.log.Info().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries") | |
| 513 | 514 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 514 | 515 | return | |
| 515 | 516 | } | |
| @@ -543,9 +544,14 @@ func (f *captureExtFrame) attempt() { | |||
| 543 | 544 | return | |
| 544 | 545 | } | |
| 545 | 546 | f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) { | |
| 546 | - | if done.Err != nil { | |
| 547 | + | switch { | |
| 548 | + | case errors.Is(done.Err, ports.ErrCaptureTransient): | |
| 547 | 549 | f.retry(done.Err.Error()) | |
| 548 | 550 | return | |
| 551 | + | case done.Err != nil: | |
| 552 | + | // Retrying cannot clear it (a bad buffer, an unsupported size). | |
| 553 | + | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 554 | + | return | |
| 549 | 555 | } | |
| 550 | 556 | f.res.SendTransform(0) | |
| 551 | 557 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "image" | 5 | "image" | ||
| 5 | "math" | 6 | "math" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -509,7 +510,7 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||||
| 509 | func (f *captureExtFrame) retry(why string) { | 510 | func (f *captureExtFrame) retry(why string) { | ||
| 510 | s := f.session.s | 511 | s := f.session.s | ||
| 511 | if !s.clock.Now().Before(f.deadline) { | 512 | 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 | + | s.log.Info().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries") |
| 513 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | 514 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | ||
| 514 | return | 515 | return | ||
| 515 | } | 516 | } | ||
| @@ -543,9 +544,14 @@ func (f *captureExtFrame) attempt() { | |||||
| 543 | return | 544 | return | ||
| 544 | } | 545 | } | ||
| 545 | f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) { | 546 | f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) { | ||
| 546 | - | if done.Err != nil { | 547 | + | switch { |
| 548 | + | case errors.Is(done.Err, ports.ErrCaptureTransient): | |||
| 547 | f.retry(done.Err.Error()) | 549 | f.retry(done.Err.Error()) | ||
| 548 | return | 550 | return | ||
| 551 | + | case done.Err != nil: | |||
| 552 | + | // Retrying cannot clear it (a bad buffer, an unsupported size). | |||
| 553 | + | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |||
| 554 | + | return | |||
| 549 | } | 555 | } | ||
| 550 | f.res.SendTransform(0) | 556 | f.res.SendTransform(0) | ||
| 551 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | 557 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | ||
M internal/adapters/wayland/screencopy.go +4 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "image" | |
| 5 | 6 | "math" | |
| 6 | 7 | "os" | |
| @@ -209,9 +210,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 209 | 210 | s.emit(taken) | |
| 210 | 211 | } | |
| 211 | 212 | ev := s.log.Debug() | |
| 212 | - | if done.Err != nil { | |
| 213 | - | ev = s.log.Info().Err(done.Err) | |
| 213 | + | if done.Err != nil && !errors.Is(done.Err, ports.ErrCaptureTransient) { | |
| 214 | + | ev = s.log.Info() | |
| 214 | 215 | } | |
| 216 | + | ev = ev.Err(done.Err) | |
| 215 | 217 | ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done") | |
| 216 | 218 | if life.Alive() { | |
| 217 | 219 | reply(done) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "image" | 5 | "image" | ||
| 5 | "math" | 6 | "math" | ||
| 6 | "os" | 7 | "os" | ||
| @@ -209,9 +210,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||||
| 209 | s.emit(taken) | 210 | s.emit(taken) | ||
| 210 | } | 211 | } | ||
| 211 | ev := s.log.Debug() | 212 | ev := s.log.Debug() | ||
| 212 | - | if done.Err != nil { | 213 | + | if done.Err != nil && !errors.Is(done.Err, ports.ErrCaptureTransient) { |
| 213 | - | ev = s.log.Info().Err(done.Err) | 214 | + | ev = s.log.Info() |
| 214 | } | 215 | } | ||
| 216 | + | ev = ev.Err(done.Err) | |||
| 215 | ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done") | 217 | ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done") | ||
| 216 | if life.Alive() { | 218 | if life.Alive() { | ||
| 217 | reply(done) | 219 | reply(done) | ||
M internal/ports/devices.go +7 -1
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 5 | 6 | "image" | |
| 6 | 7 | "os" | |
| 7 | 8 | "time" | |
| @@ -12,8 +13,13 @@ import ( | |||
| 12 | 13 | // at least this capacity, so an output never blocks on a reply. | |
| 13 | 14 | const MaxCaptureInflight = 32 | |
| 14 | 15 | ||
| 16 | + | // ErrCaptureTransient marks a capture failure that may clear on its own (the | |
| 17 | + | // renderer busy, the indicator or an exclusion not shown yet): a protocol | |
| 18 | + | // frame tries again shortly instead of failing. Test with errors.Is. | |
| 19 | + | var ErrCaptureTransient = errors.New("capture: transient") | |
| 20 | + | ||
| 15 | 21 | // ErrCaptureBusy: every capture slot of the renderer is leased. | |
| 16 | - | var ErrCaptureBusy = errors.New("capture: no free slot") | |
| 22 | + | var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) | |
| 17 | 23 | ||
| 18 | 24 | // CaptureFrame is a lease on one renderer readback slot holding a GPU | |
| 19 | 25 | // copy of a rendered frame. Done and Read make no Vulkan call: a worker | |
| @@ -2,6 +2,7 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "fmt" | |||
| 5 | "image" | 6 | "image" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -12,8 +13,13 @@ import ( | |||||
| 12 | // at least this capacity, so an output never blocks on a reply. | 13 | // at least this capacity, so an output never blocks on a reply. | ||
| 13 | const MaxCaptureInflight = 32 | 14 | const MaxCaptureInflight = 32 | ||
| 14 | 15 | ||||
| 16 | + | // ErrCaptureTransient marks a capture failure that may clear on its own (the | |||
| 17 | + | // renderer busy, the indicator or an exclusion not shown yet): a protocol | |||
| 18 | + | // frame tries again shortly instead of failing. Test with errors.Is. | |||
| 19 | + | var ErrCaptureTransient = errors.New("capture: transient") | |||
| 20 | + | ||||
| 15 | // ErrCaptureBusy: every capture slot of the renderer is leased. | 21 | // ErrCaptureBusy: every capture slot of the renderer is leased. | ||
| 16 | - | var ErrCaptureBusy = errors.New("capture: no free slot") | 22 | + | var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) |
| 17 | 23 | ||||
| 18 | // CaptureFrame is a lease on one renderer readback slot holding a GPU | 24 | // CaptureFrame is a lease on one renderer readback slot holding a GPU | ||
| 19 | // copy of a rendered frame. Done and Read make no Vulkan call: a worker | 25 | // copy of a rendered frame. Done and Read make no Vulkan call: a worker | ||