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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 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 (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 | 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 | ||
| @@ -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 | |||
| 189 | 189 | h.held = append(h.held, req) | |
| 190 | 190 | return req, true | |
| 191 | 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 | 195 | t.Fatalf("refused capture not failed: last event %d", last) | |
| 194 | 196 | } | |
| 195 | 197 | return ports.CaptureRequest{}, false | |
| @@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) { | |||
| 287 | 289 | } | |
| 288 | 290 | a.destroy(t, p) | |
| 289 | 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 | 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 | 298 | h.complete(t, req, maxCaptureInflight-1) | |
| 294 | 299 | for i, r := range reqs[1:] { | |
| 295 | 300 | 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, true | 190 | 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{}, false | 197 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "image" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync/atomic" | |
| @@ -316,6 +317,22 @@ func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEv | |||
| 316 | 317 | 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 | 336 | // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer. | |
| 320 | 337 | func (cc *cclient) wlrFrame(t *testing.T) *captureEvents { | |
| 321 | 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 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. | |
| 953 | 970 | func TestExclusionFrameTagging(t *testing.T) { | |
| 954 | 971 | h := newCaptureHarness(t) | |
| 955 | 972 | 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") | |
| 960 | 977 | } | |
| 961 | 978 | noCapture(t, h.captures) | |
| 979 | + | // Once core confirms it, the held frame is served, tagged. | |
| 962 | 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 | 981 | h.settle(t, owner) | |
| 964 | - | owner.extFrame(t, session, 4, 4) | |
| 965 | 982 | r := receiveCapture(t, h.captures) | |
| 966 | 983 | if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 { | |
| 967 | 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 | 1036 | // hud is a second connection: a layer surface it attaches with the token. | |
| 990 | 1037 | type hud struct { | |
| 991 | 1038 | *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 ev | 317 | 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 until | 967 | + | // Frames of the session that owns the exclusion are fenced: held until core |
| 951 | - | // core knows the exclusion, then tagged with its revision. Every other | 968 | + | // 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 | *cclient | 1038 | *cclient | ||
M internal/adapters/wayland/imagecopy.go +48 -6
| @@ -3,6 +3,7 @@ package wayland | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "math" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | |
| 8 | 9 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | |
| @@ -441,8 +442,20 @@ type captureExtFrame struct { | |||
| 441 | 442 | buf *wayland.Buffer | |
| 442 | 443 | used bool | |
| 443 | 444 | 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 | 459 | func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {} | |
| 447 | 460 | func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) { | |
| 448 | 461 | if f.used { | |
| @@ -488,9 +501,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 488 | 501 | f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached") | |
| 489 | 502 | 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 | 529 | o, region, hidden, ok := c.geometry() | |
| 492 | 530 | if !ok || c.haveState && !c.st.Active { | |
| 493 | - | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 531 | + | f.retry("target not ready") | |
| 494 | 532 | return | |
| 495 | 533 | } | |
| 496 | 534 | tag, ok := c.tag(hidden) | |
| @@ -501,12 +539,12 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 501 | 539 | return | |
| 502 | 540 | case !ok: | |
| 503 | 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 | 543 | return | |
| 506 | 544 | } | |
| 507 | 545 | f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) { | |
| 508 | 546 | if done.Err != nil { | |
| 509 | - | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 547 | + | f.retry(done.Err.Error()) | |
| 510 | 548 | return | |
| 511 | 549 | } | |
| 512 | 550 | f.res.SendTransform(0) | |
| @@ -516,10 +554,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 516 | 554 | f.res.SendReady() | |
| 517 | 555 | }, f.res.Resource, tag) | |
| 518 | 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 | 559 | 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") | |
| 523 | 565 | } | |
| 524 | 566 | } | |
| 525 | 567 | } | |
| @@ -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.Buffer | 442 | buf *wayland.Buffer | ||
| 442 | used bool | 443 | used bool | ||
| 443 | replyID uint64 | 444 | 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 | return | 502 | 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 | return | 532 | 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 | return | 539 | 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 | return | 543 | 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 | return | 548 | 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 { | |||
| 170 | 170 | readinessGeneration uint64 | |
| 171 | 171 | lastFlip map[string]time.Time | |
| 172 | 172 | // tokens are the issued xdg-activation tokens (activation.go). | |
| 173 | - | tokens map[string]activationToken | |
| 173 | + | tokens map[string]activationToken | |
| 174 | 174 | // foreignExports are the live xdg-foreign handles (foreign.go). | |
| 175 | 175 | foreignExports map[string]*foreignExport | |
| 176 | 176 | surfaces map[*server.Resource]*surface | |
| 177 | - | buffers map[*server.Resource]clientBuffer | |
| 178 | - | dmabuf *dmabufGlobal | |
| 177 | + | buffers map[*server.Resource]clientBuffer | |
| 178 | + | dmabuf *dmabufGlobal | |
| 179 | 179 | // hdrOutputs records confirmed DRM modesets; absent outputs are SDR. | |
| 180 | 180 | hdrOutputs map[string]ports.OutputHDR | |
| 181 | 181 | colorID uint64 | |
| @@ -170,12 +170,12 @@ type Server struct { | |||||
| 170 | readinessGeneration uint64 | 170 | readinessGeneration uint64 | ||
| 171 | lastFlip map[string]time.Time | 171 | 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]activationToken | 173 | + | 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]*foreignExport | 175 | foreignExports map[string]*foreignExport | ||
| 176 | surfaces map[*server.Resource]*surface | 176 | surfaces map[*server.Resource]*surface | ||
| 177 | - | buffers map[*server.Resource]clientBuffer | 177 | + | buffers map[*server.Resource]clientBuffer |
| 178 | - | dmabuf *dmabufGlobal | 178 | + | 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.OutputHDR | 180 | hdrOutputs map[string]ports.OutputHDR | ||
| 181 | colorID uint64 | 181 | colorID uint64 | ||