detached 760a012

#24 fix(drm): allocate output images with GBM and import them into Vulkan

state
merged · 3c13b3d · merge commit
branch
fix/gbm-scanout-images → main
opened
bnema ·
updated

Problem

On the NVIDIA proprietary driver, the screen shows heavy corruption: horizontal bands, repeated fragments and large black or grey areas. KMS accepts the output framebuffers (modifier 0x300000000606014, block-linear) and nothing crashes.

NeferWL allocated its two output images with Vulkan and exported them to KMS. NVIDIA does not scan these images out correctly. gamescope hits the same bug with the same modifier (ValveSoftware/gamescope#2309). wlroots, Hyprland/Aquamarine, niri/Smithay and gamescope's fix all allocate scanout buffers with GBM instead.

Fix

  • GBM allocation: output images are allocated with GBM (SCANOUT | RENDERING) on the KMS card. The modifier list is the plane's IN_FORMATS intersected with the modifiers the renderer can import. libgbm is loaded with purego, so the build stays CGO_ENABLED=0.
  • Vulkan import: the images are imported into Vulkan with explicit modifier layouts. Rendering is still zero-copy.
  • Fallback (deprecated, neferwl#23): for each image kind (driver, single-plane, linear), the renderer's Vulkan export is used when GBM is missing or fails. The zero-copy output line is then a warning with "alloc":"vulkan".
  • Old nvidia-drm: before 560.35.3, IN_FENCE_FD makes commits fail (NVIDIA/open-gpu-kernel-modules#622). On these versions the render fence is waited on the CPU, and a client buffer whose fence is still pending is composed rather than scanned out.

Verified

  • make check, make race, make mocks-check and staticcheck ./... pass.
  • The GPU test (GBM allocation, Vulkan import, render, pixel readback) passes on AMD RX 9070 (GBM picks 0x200000000082305) and on NVIDIA RTX 3070 Ti, driver 615.78.08, render node only (GBM picks 0x300000000606013).
  • AMD, real KMS: two outputs (5120×2160@165, 3840×2160@120) run on "alloc":"gbm" with the same modifier as before, a clean display, and workspace and VT switching.
  • NVIDIA, real KMS: a user confirmed the display works on nvidia-drm (DP-1 3440×1440, "alloc":"gbm", modifier 0x300000000606014).

Not verified

  • The IN_FENCE_FD gate on an nvidia-drm older than 560.35.3.
  • HDR (XRGB2101010) with GBM images.

Follow-ups

  • neferwl#22: the hardware cursor plane is refused on nvidia-drm.
  • neferwl#23: remove the deprecated Vulkan-export fallback.
CommitSubjectAuthorDateSignatureStatus
760a012refactor(drm): mark the Vulkan-export output image fallback deprecatedbriceGPG Verified
f0c31c4refactor(drm): simplify GBM image lifecycle and harden the fence gatebriceGPG Verified
53269c8fix(drm): allocate output images with GBM and import them into VulkanbriceGPG Verified

Checks 1

47 files · +2460 -588
1/1
M .github/workflows/release.yml +8 -3
@@ -8,10 +8,7 @@ concurrency:
88 group: release-${{ github.ref }}
99 cancel-in-progress: false
1010 jobs:
11- # Releases are published by ermite (git.bnema.dev); the GitHub mirror
12- # receives the tags but must not publish a second release or AUR push.
1311 check:
14- if: github.server_url != 'https://github.com'
1512 uses: ./.github/workflows/ci.yml
1613
1714 release:
@@ -19,6 +16,8 @@ jobs:
1916 runs-on: ubuntu-latest
2017 permissions:
2118 contents: write # create the release
19+ id-token: write # sign the provenance attestation
20+ attestations: write
2221 steps:
2322 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2423 with:
@@ -45,6 +44,12 @@ jobs:
4544 args: release --clean
4645 env:
4746 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+ - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
48+ with:
49+ subject-path: |
50+ dist/*.tar.gz
51+ dist/*.deb
52+ dist/checksums.txt
4853 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
4954 with:
5055 name: aur-bin
@@ -8,10 +8,7 @@ concurrency:
8 group: release-${{ github.ref }}8 group: release-${{ github.ref }}
9 cancel-in-progress: false9 cancel-in-progress: false
10 jobs:10 jobs:
11- # Releases are published by ermite (git.bnema.dev); the GitHub mirror
12- # receives the tags but must not publish a second release or AUR push.
13 check:11 check:
14- if: github.server_url != 'https://github.com'
15 uses: ./.github/workflows/ci.yml12 uses: ./.github/workflows/ci.yml
16 13
17 release:14 release:
@@ -19,6 +16,8 @@ jobs:
19 runs-on: ubuntu-latest16 runs-on: ubuntu-latest
20 permissions:17 permissions:
21 contents: write # create the release18 contents: write # create the release
19+ id-token: write # sign the provenance attestation
20+ attestations: write
22 steps:21 steps:
23 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.122 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24 with:23 with:
@@ -45,6 +44,12 @@ jobs:
45 args: release --clean44 args: release --clean
46 env:45 env:
47 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}46 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+ - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
48+ with:
49+ subject-path: |
50+ dist/*.tar.gz
51+ dist/*.deb
52+ dist/checksums.txt
48 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.153 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
49 with:54 with:
50 name: aur-bin55 name: aur-bin
M .goreleaser.yaml +12 -19
@@ -1,18 +1,10 @@
1-# Builds the ermite release for a pushed v* tag (.github/workflows/release.yml)
1+# Builds the GitHub release for a pushed v* tag (.github/workflows/release.yml)
22 # and generates the neferwl-bin PKGBUILD. The workflow verifies that PKGBUILD
33 # with makepkg before it pushes it to the AUR, so GoReleaser never uploads it.
44 version: 2
55
66 project_name: neferwl
77
8-# Releases are published on ermite. In CI the runner sets GITHUB_API_URL and
9-# GITHUB_SERVER_URL to an address the job can reach; the public URLs are the
10-# fallback for a local run.
11-github_urls:
12- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/'
13- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/uploads/'
14- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.bnema.dev" }}/'
15-
168 before:
179 hooks:
1810 - go mod verify
@@ -57,7 +49,7 @@ nfpms:
5749 file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Arch }}"
5850 formats: [deb]
5951 vendor: bnema
60- homepage: https://git.bnema.dev/neferwl
52+ homepage: https://github.com/bnema/neferwl
6153 maintainer: "bnema <b@bnema.dev>"
6254 description: A Wayland compositor that spends its frames on your apps, not on itself
6355 license: GPL-3.0-only
@@ -116,7 +108,7 @@ git:
116108
117109 changelog:
118110 sort: asc
119- use: git
111+ use: github
120112 filters:
121113 exclude:
122114 - "^style(\\([^)]+\\))?:"
@@ -165,9 +157,9 @@ release:
165157 Linux amd64 and arm64. Replace `amd64` with `arm64` on ARM.
166158
167159 ```sh
168- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/neferwl_{{ .Version }}_linux_amd64.tar.gz
169- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/checksums.txt
160+ gh release download {{ .Tag }} -R bnema/neferwl -p 'neferwl_{{ .Version }}_linux_amd64.tar.gz' -p checksums.txt
170161 sha256sum -c --ignore-missing checksums.txt
162+ gh attestation verify neferwl_{{ .Version }}_linux_amd64.tar.gz -R bnema/neferwl
171163 ```
172164
173165 ### Install on Ubuntu 24.04 and 26.04
@@ -175,20 +167,21 @@ release:
175167 Replace `amd64` with `arm64` on ARM.
176168
177169 ```sh
178- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/neferwl_{{ .Version }}_amd64.deb
179- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/checksums.txt
170+ gh release download {{ .Tag }} -R bnema/neferwl -p 'neferwl_{{ .Version }}_amd64.deb' -p checksums.txt
180171 sha256sum -c --ignore-missing checksums.txt
181172 sudo apt install ./neferwl_{{ .Version }}_amd64.deb
182173 ```
183174
184- The archive holds the `neferwl` binary, the session files from `packaging/` and `examples/config`. See the [README](https://git.bnema.dev/neferwl) for runtime dependencies and the session setup.
175+ The archive holds the `neferwl` binary, the session files from `packaging/` and `examples/config`. See the [README](https://github.com/bnema/neferwl#readme) for runtime dependencies and the session setup.
176+ footer: |
177+ ---
178+
179+ **Full changelog**: https://github.com/bnema/neferwl/compare/{{ .PreviousTag }}...{{ .Tag }}
185180
186181 aurs:
187182 - name: neferwl-bin
188183 ids: [neferwl]
189- homepage: https://git.bnema.dev/neferwl
190- # Public URL: github_urls.download is the CI-only relay address.
191- url_template: 'https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/{{ .ArtifactName }}'
184+ homepage: https://github.com/bnema/neferwl
192185 description: A Wayland compositor that spends its frames on your apps, not on itself
193186 maintainers:
194187 - "bnema <b at bnema dot dev>"
@@ -1,18 +1,10 @@
1-# Builds the ermite release for a pushed v* tag (.github/workflows/release.yml)1+# Builds the GitHub release for a pushed v* tag (.github/workflows/release.yml)
2 # and generates the neferwl-bin PKGBUILD. The workflow verifies that PKGBUILD2 # and generates the neferwl-bin PKGBUILD. The workflow verifies that PKGBUILD
3 # with makepkg before it pushes it to the AUR, so GoReleaser never uploads it.3 # with makepkg before it pushes it to the AUR, so GoReleaser never uploads it.
4 version: 24 version: 2
5 5
6 project_name: neferwl6 project_name: neferwl
7 7
8-# Releases are published on ermite. In CI the runner sets GITHUB_API_URL and
9-# GITHUB_SERVER_URL to an address the job can reach; the public URLs are the
10-# fallback for a local run.
11-github_urls:
12- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/'
13- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/uploads/'
14- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.bnema.dev" }}/'
15-
16 before:8 before:
17 hooks:9 hooks:
18 - go mod verify10 - go mod verify
@@ -57,7 +49,7 @@ nfpms:
57 file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Arch }}"49 file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Arch }}"
58 formats: [deb]50 formats: [deb]
59 vendor: bnema51 vendor: bnema
60- homepage: https://git.bnema.dev/neferwl52+ homepage: https://github.com/bnema/neferwl
61 maintainer: "bnema <b@bnema.dev>"53 maintainer: "bnema <b@bnema.dev>"
62 description: A Wayland compositor that spends its frames on your apps, not on itself54 description: A Wayland compositor that spends its frames on your apps, not on itself
63 license: GPL-3.0-only55 license: GPL-3.0-only
@@ -116,7 +108,7 @@ git:
116 108
117 changelog:109 changelog:
118 sort: asc110 sort: asc
119- use: git111+ use: github
120 filters:112 filters:
121 exclude:113 exclude:
122 - "^style(\\([^)]+\\))?:"114 - "^style(\\([^)]+\\))?:"
@@ -165,9 +157,9 @@ release:
165 Linux amd64 and arm64. Replace `amd64` with `arm64` on ARM.157 Linux amd64 and arm64. Replace `amd64` with `arm64` on ARM.
166 158
167 ```sh159 ```sh
168- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/neferwl_{{ .Version }}_linux_amd64.tar.gz160+ gh release download {{ .Tag }} -R bnema/neferwl -p 'neferwl_{{ .Version }}_linux_amd64.tar.gz' -p checksums.txt
169- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/checksums.txt
170 sha256sum -c --ignore-missing checksums.txt161 sha256sum -c --ignore-missing checksums.txt
162+ gh attestation verify neferwl_{{ .Version }}_linux_amd64.tar.gz -R bnema/neferwl
171 ```163 ```
172 164
173 ### Install on Ubuntu 24.04 and 26.04165 ### Install on Ubuntu 24.04 and 26.04
@@ -175,20 +167,21 @@ release:
175 Replace `amd64` with `arm64` on ARM.167 Replace `amd64` with `arm64` on ARM.
176 168
177 ```sh169 ```sh
178- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/neferwl_{{ .Version }}_amd64.deb170+ gh release download {{ .Tag }} -R bnema/neferwl -p 'neferwl_{{ .Version }}_amd64.deb' -p checksums.txt
179- curl -fLO https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/checksums.txt
180 sha256sum -c --ignore-missing checksums.txt171 sha256sum -c --ignore-missing checksums.txt
181 sudo apt install ./neferwl_{{ .Version }}_amd64.deb172 sudo apt install ./neferwl_{{ .Version }}_amd64.deb
182 ```173 ```
183 174
184- The archive holds the `neferwl` binary, the session files from `packaging/` and `examples/config`. See the [README](https://git.bnema.dev/neferwl) for runtime dependencies and the session setup.175+ The archive holds the `neferwl` binary, the session files from `packaging/` and `examples/config`. See the [README](https://github.com/bnema/neferwl#readme) for runtime dependencies and the session setup.
176+ footer: |
177+ ---
178+
179+ **Full changelog**: https://github.com/bnema/neferwl/compare/{{ .PreviousTag }}...{{ .Tag }}
185 180
186 aurs:181 aurs:
187 - name: neferwl-bin182 - name: neferwl-bin
188 ids: [neferwl]183 ids: [neferwl]
189- homepage: https://git.bnema.dev/neferwl184+ homepage: https://github.com/bnema/neferwl
190- # Public URL: github_urls.download is the CI-only relay address.
191- url_template: 'https://git.bnema.dev/neferwl/releases/download/{{ .Tag }}/{{ .ArtifactName }}'
192 description: A Wayland compositor that spends its frames on your apps, not on itself185 description: A Wayland compositor that spends its frames on your apps, not on itself
193 maintainers:186 maintainers:
194 - "bnema <b at bnema dot dev>"187 - "bnema <b at bnema dot dev>"
M .mockery.yml +10 -0
@@ -34,6 +34,16 @@ packages:
3434 dir: internal/adapters/drm
3535 pkgname: drm
3636 filename: kms_mock_test.go
37+ imageAllocator:
38+ config:
39+ dir: internal/adapters/drm
40+ pkgname: drm
41+ filename: image_allocator_mock_test.go
42+ imageBuffer:
43+ config:
44+ dir: internal/adapters/drm
45+ pkgname: drm
46+ filename: image_buffer_mock_test.go
3747 github.com/bnema/neferwl/internal/adapters/seat:
3848 interfaces:
3949 disabler:
@@ -34,6 +34,16 @@ packages:
34 dir: internal/adapters/drm34 dir: internal/adapters/drm
35 pkgname: drm35 pkgname: drm
36 filename: kms_mock_test.go36 filename: kms_mock_test.go
37+ imageAllocator:
38+ config:
39+ dir: internal/adapters/drm
40+ pkgname: drm
41+ filename: image_allocator_mock_test.go
42+ imageBuffer:
43+ config:
44+ dir: internal/adapters/drm
45+ pkgname: drm
46+ filename: image_buffer_mock_test.go
37 github.com/bnema/neferwl/internal/adapters/seat:47 github.com/bnema/neferwl/internal/adapters/seat:
38 interfaces:48 interfaces:
39 disabler:49 disabler:
M README.md +4 -3
@@ -8,7 +8,8 @@
88
99 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>
1010
11-https://github.com/user-attachments/assets/1ff42ce5-47b3-4cf8-a3a7-5fc5abde4af1
11+https://github.com/user-attachments/assets/54f25914-8335-4e66-b458-dabc5493d355
12+
1213
1314 <p align="center"><sub>Stash, overview and animations, recorded on the headless backend at 1080p.</sub></p>
1415
@@ -47,7 +48,7 @@ make install # build a package from HEAD and install it with pacman
4748
4849 ### Ubuntu 24.04 and later
4950
50-Download the `.deb` for your architecture (`amd64` or `arm64`) and `checksums.txt` from [Releases](https://git.bnema.dev/neferwl/releases), then install it with apt, which pulls the runtime dependencies:
51+Download the `.deb` for your architecture (`amd64` or `arm64`) and `checksums.txt` from [Releases](https://github.com/bnema/neferwl/releases), then install it with apt, which pulls the runtime dependencies:
5152
5253 ```sh
5354 sha256sum -c --ignore-missing checksums.txt
@@ -72,7 +73,7 @@ Copy `/usr/share/doc/neferwl/config.example` to `~/.config/neferwl/config` to st
7273
7374 ### Other distributions
7475
75-Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://git.bnema.dev/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.
76+Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://github.com/bnema/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.
7677
7778 ## Configuration
7879
@@ -8,7 +8,8 @@
8 8
9 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>9 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>
10 10
11-https://github.com/user-attachments/assets/1ff42ce5-47b3-4cf8-a3a7-5fc5abde4af111+https://github.com/user-attachments/assets/54f25914-8335-4e66-b458-dabc5493d355
12+
12 13
13 <p align="center"><sub>Stash, overview and animations, recorded on the headless backend at 1080p.</sub></p>14 <p align="center"><sub>Stash, overview and animations, recorded on the headless backend at 1080p.</sub></p>
14 15
@@ -47,7 +48,7 @@ make install # build a package from HEAD and install it with pacman
47 48
48 ### Ubuntu 24.04 and later49 ### Ubuntu 24.04 and later
49 50
50-Download the `.deb` for your architecture (`amd64` or `arm64`) and `checksums.txt` from [Releases](https://git.bnema.dev/neferwl/releases), then install it with apt, which pulls the runtime dependencies:51+Download the `.deb` for your architecture (`amd64` or `arm64`) and `checksums.txt` from [Releases](https://github.com/bnema/neferwl/releases), then install it with apt, which pulls the runtime dependencies:
51 52
52 ```sh53 ```sh
53 sha256sum -c --ignore-missing checksums.txt54 sha256sum -c --ignore-missing checksums.txt
@@ -72,7 +73,7 @@ Copy `/usr/share/doc/neferwl/config.example` to `~/.config/neferwl/config` to st
72 73
73 ### Other distributions74 ### Other distributions
74 75
75-Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://git.bnema.dev/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.76+Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://github.com/bnema/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.
76 77
77 ## Configuration78 ## Configuration
78 79
M docs/dev/architecture.md +9 -1
@@ -20,10 +20,18 @@ About 35 channels connect them, all created in one place, so the full data flow
2020
2121 - **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
2222 - **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](../performance.md).
23-- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
23+- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan, libinput and GBM are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
2424 - **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
2525 - **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
2626
27+### Output images
28+
29+A DRM output flips between two images the renderer draws into, with no CPU copy.
30+
31+- **Allocation:** the images are allocated with GBM (`GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING`), with the modifiers the primary plane lists that the renderer can render into and import. The renderer imports them as Vulkan images, and KMS gets framebuffers made from the same dmabufs. GBM is the display driver's own allocator, so the layouts it picks scan out correctly on every driver.
32+- **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
33+- **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
34+
2735 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
2836
2937 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
@@ -20,10 +20,18 @@ About 35 channels connect them, all created in one place, so the full data flow
20 20
21 - **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.21 - **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
22 - **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](../performance.md).22 - **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](../performance.md).
23-- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.23+- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan, libinput and GBM are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
24 - **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.24 - **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
25 - **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.25 - **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
26 26
27+### Output images
28+
29+A DRM output flips between two images the renderer draws into, with no CPU copy.
30+
31+- **Allocation:** the images are allocated with GBM (`GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING`), with the modifiers the primary plane lists that the renderer can render into and import. The renderer imports them as Vulkan images, and KMS gets framebuffers made from the same dmabufs. GBM is the display driver's own allocator, so the layouts it picks scan out correctly on every driver.
32+- **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
33+- **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
34+
27 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:35 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
28 36
29 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.37 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
M docs/dev/headless.md +5 -9
@@ -7,7 +7,7 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
77 WAYLAND_DISPLAY=<name from the log> foot
88 ```
99
10-- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. `latest.png` is a hard link to the newest frame, so replace it rather than editing it in place.
10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
1111 - `--size 640x360,640x360` creates two outputs, arranged left to right,
1212 for testing `focus-monitor-left/right` and moving columns between outputs.
1313 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
@@ -25,14 +25,10 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
2525 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
2626
2727 ### Demo recording
28-Run `tools/demo/record.sh`. It requires Go 1.27, rootless Docker, ffmpeg, network access and an AMD GPU.
29-It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.
30-The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.
31-The image only carries Mesa's radeon Vulkan driver, and hint scans need the sync files that a hardware driver exports; the script stops early without an AMD render node.
32-Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
33-A FIFO feeds `input.txt` while the script launches the apps on the same clock.
34-The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`).
35-The recording only counts if the compositor stops on the quit key that ends `input.txt`; reaching its `--timeout` first fails the run as truncated.
28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.
29+It builds an Arch app image and the host compositor, then records the 1080p README demo.
30+Apps run in a disposable, offline container; only the headless Wayland socket is mounted.
31+A FIFO synchronizes `input.txt` with app launches after container startup.
3632 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
3733 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
3834
@@ -7,7 +7,7 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
7 WAYLAND_DISPLAY=<name from the log> foot7 WAYLAND_DISPLAY=<name from the log> foot
8 ```8 ```
9 9
10-- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. `latest.png` is a hard link to the newest frame, so replace it rather than editing it in place.10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
11 - `--size 640x360,640x360` creates two outputs, arranged left to right,11 - `--size 640x360,640x360` creates two outputs, arranged left to right,
12 for testing `focus-monitor-left/right` and moving columns between outputs.12 for testing `focus-monitor-left/right` and moving columns between outputs.
13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
@@ -25,14 +25,10 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
26 26
27 ### Demo recording27 ### Demo recording
28-Run `tools/demo/record.sh`. It requires Go 1.27, rootless Docker, ffmpeg, network access and an AMD GPU.28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.
29-It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.29+It builds an Arch app image and the host compositor, then records the 1080p README demo.
30-The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.30+Apps run in a disposable, offline container; only the headless Wayland socket is mounted.
31-The image only carries Mesa's radeon Vulkan driver, and hint scans need the sync files that a hardware driver exports; the script stops early without an AMD render node.31+A FIFO synchronizes `input.txt` with app launches after container startup.
32-Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
33-A FIFO feeds `input.txt` while the script launches the apps on the same clock.
34-The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`).
35-The recording only counts if the compositor stops on the quit key that ends `input.txt`; reaching its `--timeout` first fails the run as truncated.
36 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.32 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
37 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.33 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
38 34
M docs/media/demo.mp4 +0 -0 binary file: no text diff

binary file: no text diff

M docs/media/demo.png +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/drm/alloc.go +50 -0
@@ -0,0 +1,50 @@
1+package drm
2+
3+import (
4+ "github.com/bnema/neferwl/internal/adapters/drm/gbm"
5+ "github.com/bnema/neferwl/internal/ports"
6+)
7+
8+// imageAllocator makes the output images with the display driver's own
9+// allocator (GBM), which knows the layouts the driver scans out correctly:
10+// images allocated by the renderer show corrupted on NVIDIA. One per
11+// output, used by its goroutine only.
12+type imageAllocator interface {
13+ // create allocates an image whose modifier is one of modifiers.
14+ create(width, height int, format uint32, modifiers []uint64) (imageBuffer, error)
15+ close()
16+}
17+
18+// imageBuffer is one allocated image, kept alive while it is shown.
19+type imageBuffer interface {
20+ // dmabuf returns the image with fresh descriptors the caller closes.
21+ dmabuf() (ports.DMABuf, error)
22+ close()
23+}
24+
25+// gbmAllocator allocates scanout-capable, renderable images with libgbm.
26+type gbmAllocator struct{ dev *gbm.Device }
27+
28+// openGBM opens a GBM allocator on a duplicate of the card fd.
29+func openGBM(fd int) (imageAllocator, error) {
30+ dev, err := gbm.Open(fd)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return gbmAllocator{dev}, nil
35+}
36+
37+func (a gbmAllocator) create(width, height int, format uint32, modifiers []uint64) (imageBuffer, error) {
38+ b, err := a.dev.Create(width, height, format, modifiers, gbm.UseScanout|gbm.UseRendering)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return gbmImage{b}, nil
43+}
44+
45+func (a gbmAllocator) close() { a.dev.Close() }
46+
47+type gbmImage struct{ b *gbm.Buffer }
48+
49+func (i gbmImage) dmabuf() (ports.DMABuf, error) { return i.b.DMABuf() }
50+func (i gbmImage) close() { i.b.Close() }
@@ -0,0 +1,50 @@
1+package drm
2+
3+import (
4+ "github.com/bnema/neferwl/internal/adapters/drm/gbm"
5+ "github.com/bnema/neferwl/internal/ports"
6+)
7+
8+// imageAllocator makes the output images with the display driver's own
9+// allocator (GBM), which knows the layouts the driver scans out correctly:
10+// images allocated by the renderer show corrupted on NVIDIA. One per
11+// output, used by its goroutine only.
12+type imageAllocator interface {
13+ // create allocates an image whose modifier is one of modifiers.
14+ create(width, height int, format uint32, modifiers []uint64) (imageBuffer, error)
15+ close()
16+}
17+
18+// imageBuffer is one allocated image, kept alive while it is shown.
19+type imageBuffer interface {
20+ // dmabuf returns the image with fresh descriptors the caller closes.
21+ dmabuf() (ports.DMABuf, error)
22+ close()
23+}
24+
25+// gbmAllocator allocates scanout-capable, renderable images with libgbm.
26+type gbmAllocator struct{ dev *gbm.Device }
27+
28+// openGBM opens a GBM allocator on a duplicate of the card fd.
29+func openGBM(fd int) (imageAllocator, error) {
30+ dev, err := gbm.Open(fd)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return gbmAllocator{dev}, nil
35+}
36+
37+func (a gbmAllocator) create(width, height int, format uint32, modifiers []uint64) (imageBuffer, error) {
38+ b, err := a.dev.Create(width, height, format, modifiers, gbm.UseScanout|gbm.UseRendering)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return gbmImage{b}, nil
43+}
44+
45+func (a gbmAllocator) close() { a.dev.Close() }
46+
47+type gbmImage struct{ b *gbm.Buffer }
48+
49+func (i gbmImage) dmabuf() (ports.DMABuf, error) { return i.b.DMABuf() }
50+func (i gbmImage) close() { i.b.Close() }
A internal/adapters/drm/alloc_test.go +234 -0
@@ -0,0 +1,234 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// gbmBuf is a dmabuf over a closed pipe, as an image buffer hands out.
15+func gbmBuf(planes int) ports.DMABuf {
16+ b := ports.DMABuf{Modifier: tiledMod}
17+ for range planes {
18+ f, w, _ := os.Pipe()
19+ w.Close()
20+ b.Planes = append(b.Planes, ports.DMABufPlane{File: f})
21+ }
22+ return b
23+}
24+
25+// twoGBMImages makes an allocator that hands out two images of the given
26+// plane count; each image closes exactly once, counted in closed.
27+func twoGBMImages(t *testing.T, planes int) (a imageAllocator, closed *int) {
28+ m := newMockimageAllocator(t)
29+ closed = new(int)
30+ m.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).RunAndReturn(func(int, int, uint32, []uint64) (imageBuffer, error) {
31+ img := newMockimageBuffer(t)
32+ img.EXPECT().dmabuf().RunAndReturn(func() (ports.DMABuf, error) { return gbmBuf(planes), nil })
33+ img.EXPECT().close().Run(func() { *closed++ }).Once()
34+ return img, nil
35+ }).Twice()
36+ m.EXPECT().close().Return().Maybe()
37+ return m, closed
38+}
39+
40+func gbmRenderer(t *testing.T) *portsmocks.MockRenderer {
41+ r := portsmocks.NewMockRenderer(t)
42+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
43+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
44+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
45+ return r
46+}
47+
48+// GBM images come first: the renderer imports them, the framebuffers are
49+// made from them, and no image is exported.
50+func TestShowImagesAllocatesWithGBMFirst(t *testing.T) {
51+ o, k, _ := testOutput(t)
52+ o.cursor = nil
53+ a, closed := twoGBMImages(t, 1)
54+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
55+ r := gbmRenderer(t)
56+ r.EXPECT().TargetModifiers([]uint64(nil), false).Return([]uint64{tiledMod}).Once()
57+ r.EXPECT().ImportTargets(mock.MatchedBy(func(b []ports.DMABuf) bool { return len(b) == 2 && b[0].Modifier == tiledMod })).Return(nil).Once()
58+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
59+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
60+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
61+ k.EXPECT().rmFB(mock.Anything).Return(nil)
62+ if err := o.showImages(r, imagesDriver, nil); err != nil {
63+ t.Fatal(err)
64+ }
65+ if o.kind != imagesDriver || o.imageMod != tiledMod || len(o.images) != 2 || *closed != 0 {
66+ t.Fatalf("kind %d modifier %#x images %d closed %d", o.kind, o.imageMod, len(o.images), *closed)
67+ }
68+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
69+ o.dropImages(r)
70+ if *closed != 2 || o.images != nil || o.fbs != [2]uint32{} {
71+ t.Fatalf("closed %d images, %d left, fbs %v", *closed, len(o.images), o.fbs)
72+ }
73+}
74+
75+// A GBM failure at any step falls back to the renderer's own images of the same kind.
76+func TestShowImagesFallsBackToVulkanWhenGBMFails(t *testing.T) {
77+ for name, tc := range map[string]struct {
78+ setup func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer)
79+ }{
80+ "gbm unavailable": {func(_ *testing.T, o *Output, _ *mockkms, _ *portsmocks.MockRenderer) {
81+ o.openAlloc = func() (imageAllocator, error) { return nil, errors.New("no libgbm") }
82+ }},
83+ "no common modifier": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
84+ a := newMockimageAllocator(t)
85+ a.EXPECT().close().Return().Maybe()
86+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
87+ r.EXPECT().TargetModifiers(mock.Anything, false).Return(nil).Once()
88+ }},
89+ "allocation fails": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
90+ a := newMockimageAllocator(t)
91+ a.EXPECT().close().Return().Maybe()
92+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(nil, errors.New("gbm: no buffer")).Once()
93+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
94+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
95+ }},
96+ "second allocation fails": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
97+ a := newMockimageAllocator(t)
98+ a.EXPECT().close().Return().Maybe()
99+ img := newMockimageBuffer(t)
100+ img.EXPECT().dmabuf().Return(gbmBuf(1), nil).Once()
101+ img.EXPECT().close().Return().Once() // freed with the failed attempt
102+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
103+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(img, nil).Once()
104+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(nil, errors.New("out of memory")).Once()
105+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
106+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
107+ }},
108+ "import refused": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
109+ a, _ := twoGBMImages(t, 1)
110+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
111+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
112+ r.EXPECT().ImportTargets(mock.Anything).Return(errors.New("explicit layout refused")).Once()
113+ // The images made before the refusal go with a renderer drop.
114+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
115+ }},
116+ "framebuffer refused": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
117+ a, _ := twoGBMImages(t, 1)
118+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
119+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
120+ r.EXPECT().ImportTargets(mock.Anything).Return(nil).Once()
121+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
122+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(0, unix.EINVAL).Once()
123+ }},
124+ } {
125+ t.Run(name, func(t *testing.T) {
126+ o, k, _ := testOutput(t)
127+ o.cursor = nil
128+ r := gbmRenderer(t)
129+ tc.setup(t, o, k, r)
130+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
131+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Maybe()
132+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
133+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
134+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
135+ if err := o.showImages(r, imagesDriver, nil); err != nil {
136+ t.Fatal(err)
137+ }
138+ // The renderer's own images: no GBM image is alive.
139+ if o.kind != imagesDriver || len(o.images) != 0 || o.fbs[0] == 0 || o.fbs[1] == 0 {
140+ t.Fatalf("kind %d images %d fbs %v", o.kind, len(o.images), o.fbs)
141+ }
142+ })
143+ }
144+}
145+
146+// A failed GBM open is remembered: the next kind and the next setup do not
147+// open the library again.
148+func TestGBMOpenFailureIsRemembered(t *testing.T) {
149+ o, k, _ := testOutput(t, unix.EINVAL)
150+ o.cursor = nil
151+ opens := 0
152+ o.openAlloc = func() (imageAllocator, error) { opens++; return nil, errors.New("no libgbm") }
153+ r := gbmRenderer(t)
154+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(2), gbmBuf(2)}, nil).Once()
155+ r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
156+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
157+ k.EXPECT().rmFB(mock.Anything).Return(nil)
158+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
159+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
160+ if err := o.showImages(r, imagesDriver, nil); err != nil {
161+ t.Fatal(err)
162+ }
163+ if opens != 1 || o.kind != imagesSinglePlane {
164+ t.Fatalf("%d opens, kind %d", opens, o.kind)
165+ }
166+}
167+
168+// A modeset test that refuses GBM images tries the renderer's images of the
169+// same kind before the next kind.
170+func TestShowImagesRefusedGBMImagesTryVulkanOfTheSameKind(t *testing.T) {
171+ o, k, commits := testOutput(t, unix.EINVAL)
172+ o.cursor = nil
173+ a, closed := twoGBMImages(t, 1)
174+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
175+ r := gbmRenderer(t)
176+ r.EXPECT().TargetModifiers([]uint64(nil), false).Return([]uint64{tiledMod}).Once()
177+ r.EXPECT().ImportTargets(mock.MatchedBy(func(b []ports.DMABuf) bool { return len(b) == 2 })).Return(nil).Once()
178+ // The refused GBM images leave no renderer target behind.
179+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
180+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
181+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
182+ k.EXPECT().rmFB(mock.Anything).Return(nil)
183+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
184+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
185+ if err := o.showImages(r, imagesDriver, nil); err != nil {
186+ t.Fatal(err)
187+ }
188+ if o.kind != imagesDriver || *closed != 2 || len(o.images) != 0 || len(*commits) != 3 {
189+ t.Fatalf("kind %d closed %d images %d commits %d", o.kind, *closed, len(o.images), len(*commits))
190+ }
191+}
192+
193+// Closing the output frees the GBM images and closes the allocator once.
194+func TestOutputCloseFreesGBMImagesAndAllocator(t *testing.T) {
195+ o, k, _ := testOutput(t)
196+ o.cursor = nil
197+ img := newMockimageBuffer(t)
198+ img.EXPECT().close().Return().Twice()
199+ alloc := newMockimageAllocator(t)
200+ alloc.EXPECT().close().Return().Once()
201+ o.images = []imageBuffer{img, img}
202+ o.alloc = alloc
203+ o.fbs = [2]uint32{70, 71}
204+ k.EXPECT().rmFB(uint32(70)).Return(nil).Once()
205+ k.EXPECT().rmFB(uint32(71)).Return(nil).Once()
206+ o.Close()
207+ if o.images != nil || o.alloc != nil || o.fbs != [2]uint32{} {
208+ t.Fatalf("images %v alloc %v fbs %v", o.images, o.alloc, o.fbs)
209+ }
210+}
211+
212+// A setup that finds images left over frees them first, with the renderer's
213+// targets over GBM ones: the renderer never keeps a target over a freed
214+// image.
215+func TestSetupStartsFromCleanState(t *testing.T) {
216+ o, k, _ := testOutput(t)
217+ o.cursor = nil
218+ img := newMockimageBuffer(t)
219+ img.EXPECT().close().Return().Once()
220+ o.images = []imageBuffer{img}
221+ o.fbs = [2]uint32{70, 71}
222+ k.EXPECT().rmFB(uint32(70)).Return(nil).Once()
223+ k.EXPECT().rmFB(uint32(71)).Return(nil).Once()
224+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(80, nil)
225+ r := gbmRenderer(t)
226+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
227+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
228+ if err := o.exportImages(r, nil, false); err != nil {
229+ t.Fatal(err)
230+ }
231+ if o.images != nil || o.fbs != [2]uint32{80, 80} {
232+ t.Fatalf("images %v fbs %v", o.images, o.fbs)
233+ }
234+}
@@ -0,0 +1,234 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// gbmBuf is a dmabuf over a closed pipe, as an image buffer hands out.
15+func gbmBuf(planes int) ports.DMABuf {
16+ b := ports.DMABuf{Modifier: tiledMod}
17+ for range planes {
18+ f, w, _ := os.Pipe()
19+ w.Close()
20+ b.Planes = append(b.Planes, ports.DMABufPlane{File: f})
21+ }
22+ return b
23+}
24+
25+// twoGBMImages makes an allocator that hands out two images of the given
26+// plane count; each image closes exactly once, counted in closed.
27+func twoGBMImages(t *testing.T, planes int) (a imageAllocator, closed *int) {
28+ m := newMockimageAllocator(t)
29+ closed = new(int)
30+ m.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).RunAndReturn(func(int, int, uint32, []uint64) (imageBuffer, error) {
31+ img := newMockimageBuffer(t)
32+ img.EXPECT().dmabuf().RunAndReturn(func() (ports.DMABuf, error) { return gbmBuf(planes), nil })
33+ img.EXPECT().close().Run(func() { *closed++ }).Once()
34+ return img, nil
35+ }).Twice()
36+ m.EXPECT().close().Return().Maybe()
37+ return m, closed
38+}
39+
40+func gbmRenderer(t *testing.T) *portsmocks.MockRenderer {
41+ r := portsmocks.NewMockRenderer(t)
42+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
43+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
44+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
45+ return r
46+}
47+
48+// GBM images come first: the renderer imports them, the framebuffers are
49+// made from them, and no image is exported.
50+func TestShowImagesAllocatesWithGBMFirst(t *testing.T) {
51+ o, k, _ := testOutput(t)
52+ o.cursor = nil
53+ a, closed := twoGBMImages(t, 1)
54+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
55+ r := gbmRenderer(t)
56+ r.EXPECT().TargetModifiers([]uint64(nil), false).Return([]uint64{tiledMod}).Once()
57+ r.EXPECT().ImportTargets(mock.MatchedBy(func(b []ports.DMABuf) bool { return len(b) == 2 && b[0].Modifier == tiledMod })).Return(nil).Once()
58+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
59+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
60+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
61+ k.EXPECT().rmFB(mock.Anything).Return(nil)
62+ if err := o.showImages(r, imagesDriver, nil); err != nil {
63+ t.Fatal(err)
64+ }
65+ if o.kind != imagesDriver || o.imageMod != tiledMod || len(o.images) != 2 || *closed != 0 {
66+ t.Fatalf("kind %d modifier %#x images %d closed %d", o.kind, o.imageMod, len(o.images), *closed)
67+ }
68+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
69+ o.dropImages(r)
70+ if *closed != 2 || o.images != nil || o.fbs != [2]uint32{} {
71+ t.Fatalf("closed %d images, %d left, fbs %v", *closed, len(o.images), o.fbs)
72+ }
73+}
74+
75+// A GBM failure at any step falls back to the renderer's own images of the same kind.
76+func TestShowImagesFallsBackToVulkanWhenGBMFails(t *testing.T) {
77+ for name, tc := range map[string]struct {
78+ setup func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer)
79+ }{
80+ "gbm unavailable": {func(_ *testing.T, o *Output, _ *mockkms, _ *portsmocks.MockRenderer) {
81+ o.openAlloc = func() (imageAllocator, error) { return nil, errors.New("no libgbm") }
82+ }},
83+ "no common modifier": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
84+ a := newMockimageAllocator(t)
85+ a.EXPECT().close().Return().Maybe()
86+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
87+ r.EXPECT().TargetModifiers(mock.Anything, false).Return(nil).Once()
88+ }},
89+ "allocation fails": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
90+ a := newMockimageAllocator(t)
91+ a.EXPECT().close().Return().Maybe()
92+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(nil, errors.New("gbm: no buffer")).Once()
93+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
94+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
95+ }},
96+ "second allocation fails": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
97+ a := newMockimageAllocator(t)
98+ a.EXPECT().close().Return().Maybe()
99+ img := newMockimageBuffer(t)
100+ img.EXPECT().dmabuf().Return(gbmBuf(1), nil).Once()
101+ img.EXPECT().close().Return().Once() // freed with the failed attempt
102+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
103+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(img, nil).Once()
104+ a.EXPECT().create(200, 100, uint32(fourccXRGB), []uint64{tiledMod}).Return(nil, errors.New("out of memory")).Once()
105+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
106+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
107+ }},
108+ "import refused": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
109+ a, _ := twoGBMImages(t, 1)
110+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
111+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
112+ r.EXPECT().ImportTargets(mock.Anything).Return(errors.New("explicit layout refused")).Once()
113+ // The images made before the refusal go with a renderer drop.
114+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
115+ }},
116+ "framebuffer refused": {func(t *testing.T, o *Output, k *mockkms, r *portsmocks.MockRenderer) {
117+ a, _ := twoGBMImages(t, 1)
118+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
119+ r.EXPECT().TargetModifiers(mock.Anything, false).Return([]uint64{tiledMod}).Once()
120+ r.EXPECT().ImportTargets(mock.Anything).Return(nil).Once()
121+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
122+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(0, unix.EINVAL).Once()
123+ }},
124+ } {
125+ t.Run(name, func(t *testing.T) {
126+ o, k, _ := testOutput(t)
127+ o.cursor = nil
128+ r := gbmRenderer(t)
129+ tc.setup(t, o, k, r)
130+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
131+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Maybe()
132+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
133+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
134+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
135+ if err := o.showImages(r, imagesDriver, nil); err != nil {
136+ t.Fatal(err)
137+ }
138+ // The renderer's own images: no GBM image is alive.
139+ if o.kind != imagesDriver || len(o.images) != 0 || o.fbs[0] == 0 || o.fbs[1] == 0 {
140+ t.Fatalf("kind %d images %d fbs %v", o.kind, len(o.images), o.fbs)
141+ }
142+ })
143+ }
144+}
145+
146+// A failed GBM open is remembered: the next kind and the next setup do not
147+// open the library again.
148+func TestGBMOpenFailureIsRemembered(t *testing.T) {
149+ o, k, _ := testOutput(t, unix.EINVAL)
150+ o.cursor = nil
151+ opens := 0
152+ o.openAlloc = func() (imageAllocator, error) { opens++; return nil, errors.New("no libgbm") }
153+ r := gbmRenderer(t)
154+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(2), gbmBuf(2)}, nil).Once()
155+ r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
156+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
157+ k.EXPECT().rmFB(mock.Anything).Return(nil)
158+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
159+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
160+ if err := o.showImages(r, imagesDriver, nil); err != nil {
161+ t.Fatal(err)
162+ }
163+ if opens != 1 || o.kind != imagesSinglePlane {
164+ t.Fatalf("%d opens, kind %d", opens, o.kind)
165+ }
166+}
167+
168+// A modeset test that refuses GBM images tries the renderer's images of the
169+// same kind before the next kind.
170+func TestShowImagesRefusedGBMImagesTryVulkanOfTheSameKind(t *testing.T) {
171+ o, k, commits := testOutput(t, unix.EINVAL)
172+ o.cursor = nil
173+ a, closed := twoGBMImages(t, 1)
174+ o.openAlloc = func() (imageAllocator, error) { return a, nil }
175+ r := gbmRenderer(t)
176+ r.EXPECT().TargetModifiers([]uint64(nil), false).Return([]uint64{tiledMod}).Once()
177+ r.EXPECT().ImportTargets(mock.MatchedBy(func(b []ports.DMABuf) bool { return len(b) == 2 })).Return(nil).Once()
178+ // The refused GBM images leave no renderer target behind.
179+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
180+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
181+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
182+ k.EXPECT().rmFB(mock.Anything).Return(nil)
183+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
184+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
185+ if err := o.showImages(r, imagesDriver, nil); err != nil {
186+ t.Fatal(err)
187+ }
188+ if o.kind != imagesDriver || *closed != 2 || len(o.images) != 0 || len(*commits) != 3 {
189+ t.Fatalf("kind %d closed %d images %d commits %d", o.kind, *closed, len(o.images), len(*commits))
190+ }
191+}
192+
193+// Closing the output frees the GBM images and closes the allocator once.
194+func TestOutputCloseFreesGBMImagesAndAllocator(t *testing.T) {
195+ o, k, _ := testOutput(t)
196+ o.cursor = nil
197+ img := newMockimageBuffer(t)
198+ img.EXPECT().close().Return().Twice()
199+ alloc := newMockimageAllocator(t)
200+ alloc.EXPECT().close().Return().Once()
201+ o.images = []imageBuffer{img, img}
202+ o.alloc = alloc
203+ o.fbs = [2]uint32{70, 71}
204+ k.EXPECT().rmFB(uint32(70)).Return(nil).Once()
205+ k.EXPECT().rmFB(uint32(71)).Return(nil).Once()
206+ o.Close()
207+ if o.images != nil || o.alloc != nil || o.fbs != [2]uint32{} {
208+ t.Fatalf("images %v alloc %v fbs %v", o.images, o.alloc, o.fbs)
209+ }
210+}
211+
212+// A setup that finds images left over frees them first, with the renderer's
213+// targets over GBM ones: the renderer never keeps a target over a freed
214+// image.
215+func TestSetupStartsFromCleanState(t *testing.T) {
216+ o, k, _ := testOutput(t)
217+ o.cursor = nil
218+ img := newMockimageBuffer(t)
219+ img.EXPECT().close().Return().Once()
220+ o.images = []imageBuffer{img}
221+ o.fbs = [2]uint32{70, 71}
222+ k.EXPECT().rmFB(uint32(70)).Return(nil).Once()
223+ k.EXPECT().rmFB(uint32(71)).Return(nil).Once()
224+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(80, nil)
225+ r := gbmRenderer(t)
226+ r.EXPECT().ImportTargets([]ports.DMABuf(nil)).Return(nil).Once()
227+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{gbmBuf(1), gbmBuf(1)}, nil).Once()
228+ if err := o.exportImages(r, nil, false); err != nil {
229+ t.Fatal(err)
230+ }
231+ if o.images != nil || o.fbs != [2]uint32{80, 80} {
232+ t.Fatalf("images %v fbs %v", o.images, o.fbs)
233+ }
234+}
M internal/adapters/drm/amdgpu_rules_test.go +1 -0
@@ -407,6 +407,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
407407 }
408408 return nil
409409 })
410+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
410411 o.cursor = nil
411412 var state atomic.Uint64
412413 state.Store(3)
@@ -407,6 +407,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
407 }407 }
408 return nil408 return nil
409 })409 })
410+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
410 o.cursor = nil411 o.cursor = nil
411 var state atomic.Uint64412 var state atomic.Uint64
412 state.Store(3)413 state.Store(3)
M internal/adapters/drm/card.go +4 -1
@@ -38,6 +38,9 @@ type Card struct {
3838 taken map[uint32]bool
3939 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.
4040 async bool
41+ // cpuFences: commits wait for fences on the CPU instead of IN_FENCE_FD
42+ // (the driver mishandles it, see fencegate.go).
43+ cpuFences bool
4144 // serials numbers the commits of every output (event userData).
4245 serials atomic.Uint64
4346 // formats receives each output's direct scanout formats (nil: none
@@ -65,7 +68,7 @@ func OpenCard(fd int, path string, want Want, log, renderLog zerowrap.Logger) (*
6568 flips[c] = make(chan flipEvent, 4)
6669 }
6770 k := kmsDevice{fd: fd, gemMu: &sync.Mutex{}, modifiers: hasCap(fd, capAddFB2Modifiers)}
68- return &Card{fd: fd, path: path, want: want, log: log, renderLog: renderLog, crtcs: crtcs, outputs: map[string]*Output{}, leasable: map[string]connector{}, leases: map[uint32]leaseRecord{}, flips: flips, k: k, taken: map[uint32]bool{}, async: hasCap(fd, capAtomicAsync)}, nil
71+ return &Card{fd: fd, path: path, want: want, log: log, renderLog: renderLog, crtcs: crtcs, outputs: map[string]*Output{}, leasable: map[string]connector{}, leases: map[uint32]leaseRecord{}, flips: flips, k: k, taken: map[uint32]bool{}, async: hasCap(fd, capAtomicAsync), cpuFences: fenceGate(fd, log)}, nil
6972 }
7073
7174 // Path is the device path, e.g. /dev/dri/card1.
@@ -38,6 +38,9 @@ type Card struct {
38 taken map[uint32]bool38 taken map[uint32]bool
39 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.39 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.
40 async bool40 async bool
41+ // cpuFences: commits wait for fences on the CPU instead of IN_FENCE_FD
42+ // (the driver mishandles it, see fencegate.go).
43+ cpuFences bool
41 // serials numbers the commits of every output (event userData).44 // serials numbers the commits of every output (event userData).
42 serials atomic.Uint6445 serials atomic.Uint64
43 // formats receives each output's direct scanout formats (nil: none46 // formats receives each output's direct scanout formats (nil: none
@@ -65,7 +68,7 @@ func OpenCard(fd int, path string, want Want, log, renderLog zerowrap.Logger) (*
65 flips[c] = make(chan flipEvent, 4)68 flips[c] = make(chan flipEvent, 4)
66 }69 }
67 k := kmsDevice{fd: fd, gemMu: &sync.Mutex{}, modifiers: hasCap(fd, capAddFB2Modifiers)}70 k := kmsDevice{fd: fd, gemMu: &sync.Mutex{}, modifiers: hasCap(fd, capAddFB2Modifiers)}
68- return &Card{fd: fd, path: path, want: want, log: log, renderLog: renderLog, crtcs: crtcs, outputs: map[string]*Output{}, leasable: map[string]connector{}, leases: map[uint32]leaseRecord{}, flips: flips, k: k, taken: map[uint32]bool{}, async: hasCap(fd, capAtomicAsync)}, nil71+ return &Card{fd: fd, path: path, want: want, log: log, renderLog: renderLog, crtcs: crtcs, outputs: map[string]*Output{}, leasable: map[string]connector{}, leases: map[uint32]leaseRecord{}, flips: flips, k: k, taken: map[uint32]bool{}, async: hasCap(fd, capAtomicAsync), cpuFences: fenceGate(fd, log)}, nil
69 }72 }
70 73
71 // Path is the device path, e.g. /dev/dri/card1.74 // Path is the device path, e.g. /dev/dri/card1.
M internal/adapters/drm/commit_test.go +1 -0
@@ -284,6 +284,7 @@ func TestShowImagesNoCommonModifierFallsBackWithThePlanesList(t *testing.T) {
284284 // single-plane attempt KMS would refuse.
285285 func TestShowImagesLinearOnlyPlaneExportsLinearFirst(t *testing.T) {
286286 o, k, commits := testOutput(t)
287+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
287288 o.cursor = nil
288289 o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: 0}}
289290 r := portsmocks.NewMockRenderer(t)
@@ -284,6 +284,7 @@ func TestShowImagesNoCommonModifierFallsBackWithThePlanesList(t *testing.T) {
284 // single-plane attempt KMS would refuse.284 // single-plane attempt KMS would refuse.
285 func TestShowImagesLinearOnlyPlaneExportsLinearFirst(t *testing.T) {285 func TestShowImagesLinearOnlyPlaneExportsLinearFirst(t *testing.T) {
286 o, k, commits := testOutput(t)286 o, k, commits := testOutput(t)
287+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
287 o.cursor = nil288 o.cursor = nil
288 o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: 0}}289 o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: 0}}
289 r := portsmocks.NewMockRenderer(t)290 r := portsmocks.NewMockRenderer(t)
A internal/adapters/drm/fencegate.go +214 -0
@@ -0,0 +1,214 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/neferwl/internal/adapters/syncfile"
14+ "github.com/bnema/zerowrap"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// Old nvidia-drm drivers mishandle IN_FENCE_FD: the flip does not wait for
19+// the fence and shows a frame the GPU still draws
20+// (NVIDIA/open-gpu-kernel-modules#622, fixed in 560.35.3). On those the
21+// output waits for each fence on the CPU and commits without it.
22+
23+const (
24+ driverNVIDIA = "nvidia-drm"
25+ // nvidiaDRMVersionFile holds the loaded module's version.
26+ nvidiaDRMVersionFile = "/sys/module/nvidia_drm/version"
27+ // ioctlVersion is DRM_IOWR('d', 0x00, struct drm_version).
28+ ioctlVersion = 0xC0406400
29+)
30+
31+// nvidiaFenceFixed is the first nvidia-drm version that honours IN_FENCE_FD.
32+var nvidiaFenceFixed = [3]int{560, 35, 3}
33+
34+// drmVersion is struct drm_version.
35+type drmVersion struct {
36+ major, minor, patch int32
37+ nameLen uint64
38+ name unsafe.Pointer
39+ dateLen uint64
40+ date unsafe.Pointer
41+ descLen uint64
42+ desc unsafe.Pointer
43+}
44+
45+// driverName is the kernel driver of the DRM device fd (DRM_IOCTL_VERSION).
46+func driverName(fd int) (string, error) {
47+ var buf [64]byte
48+ v := drmVersion{nameLen: uint64(len(buf)), name: unsafe.Pointer(&buf[0])}
49+ if err := ioctl(fd, ioctlVersion, unsafe.Pointer(&v)); err != nil {
50+ return "", err
51+ }
52+ return string(buf[:min(int(v.nameLen), len(buf))]), nil
53+}
54+
55+// parseVersion reads a dotted version such as "560.35.03" (missing parts
56+// are 0). Parts are plain digits: no sign.
57+func parseVersion(s string) ([3]int, error) {
58+ var v [3]int
59+ parts := strings.Split(strings.TrimSpace(s), ".")
60+ if len(parts) > len(v) {
61+ return v, fmt.Errorf("version %q", s)
62+ }
63+ for i, p := range parts {
64+ n, err := strconv.ParseUint(p, 10, 31)
65+ if err != nil {
66+ return v, fmt.Errorf("version %q", s)
67+ }
68+ v[i] = int(n)
69+ }
70+ return v, nil
71+}
72+
73+// versionLess reports whether a < b.
74+func versionLess(a, b [3]int) bool {
75+ for i := range a {
76+ if a[i] != b[i] {
77+ return a[i] < b[i]
78+ }
79+ }
80+ return false
81+}
82+
83+// brokenInFence reports whether the driver may mishandle IN_FENCE_FD:
84+// nvidia-drm before 560.35.3, or whose version is unknown (version is the
85+// module version file's content, err its read error).
86+func brokenInFence(driver, version string, err error) bool {
87+ if driver != driverNVIDIA {
88+ return false
89+ }
90+ if err != nil {
91+ return true
92+ }
93+ v, perr := parseVersion(version)
94+ return perr != nil || versionLess(v, nvidiaFenceFixed)
95+}
96+
97+// fenceGate decides, once per card, whether commits carry no IN_FENCE_FD.
98+func fenceGate(fd int, log zerowrap.Logger) bool {
99+ driver, err := driverName(fd)
100+ if err != nil {
101+ log.Debug().Err(err).Msg("drm driver name unavailable")
102+ return false
103+ }
104+ var version string
105+ var verr error
106+ if driver == driverNVIDIA {
107+ b, rerr := os.ReadFile(nvidiaDRMVersionFile)
108+ version, verr = string(b), rerr
109+ }
110+ if !brokenInFence(driver, version, verr) {
111+ return false
112+ }
113+ log.Warn().Err(verr).Str("driver", driver).Str("version", strings.TrimSpace(version)).Msg("nvidia-drm version unknown or before 560.35.3 mishandles IN_FENCE_FD: waiting for frame fences on the CPU")
114+ return true
115+}
116+
117+// fenceProp sets IN_FENCE_FD on plane p to the fence, unless there is none
118+// or the fences are waited for on the CPU (waitFences).
119+func (o *Output) fenceProp(req *atomicReq, p *plane, fence *os.File) {
120+ if fence == nil || o.cpuFences {
121+ return
122+ }
123+ req.set(p.id, p.prop("IN_FENCE_FD"), uint64(fence.Fd()))
124+}
125+
126+// fenceWaitLimit bounds the CPU wait of one commit, whatever its number of
127+// fences.
128+const fenceWaitLimit = time.Second
129+
130+// fenceWarnEvery is the least time between two warnings about a fence wait
131+// that hit fenceWaitLimit.
132+const fenceWarnEvery = 10 * time.Second
133+
134+// fencePoll polls one fence without blocking. fn is built once, so polling
135+// allocates nothing; the output goroutine owns it.
136+type fencePoll struct {
137+ fds [1]unix.PollFd
138+ ready bool
139+ fn func(fd uintptr)
140+}
141+
142+func (p *fencePoll) init() {
143+ p.fn = func(fd uintptr) {
144+ p.fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
145+ n, err := unix.Poll(p.fds[:], 0)
146+ p.ready = err == nil && n > 0 && p.fds[0].Revents&unix.POLLIN != 0
147+ }
148+}
149+
150+// clientFenceReady reports whether a client's acquire fence lets its buffer
151+// be shown by a plane this frame. Where fences are waited for on the CPU, a
152+// client fence that has not signalled must not stall the output: the buffer
153+// is composed instead (the renderer waits for it on the GPU). Without CPU
154+// waits, and for no fence, it is ready; with them, a fence that cannot be
155+// polled is not ready.
156+func (o *Output) clientFenceReady(acquire *os.File) bool {
157+ if !o.cpuFences || acquire == nil {
158+ return true
159+ }
160+ if o.fencePoll.fn == nil {
161+ o.fencePoll.init()
162+ }
163+ raw, err := acquire.SyscallConn()
164+ if err != nil {
165+ return false
166+ }
167+ o.fencePoll.ready = false
168+ // Control keeps the descriptor open for the call: Wayland closing its
169+ // file meanwhile cannot hand us a reused number.
170+ if raw.Control(o.fencePoll.fn) != nil {
171+ return false
172+ }
173+ return o.fencePoll.ready
174+}
175+
176+// waitFences blocks until the fences signalled when the driver cannot wait
177+// for them in the commit, for at most fenceWaitLimit in all, or until the
178+// output stops. A client fence passed here (direct scanout) already
179+// signalled: planes take only such buffers (clientFenceReady), so it
180+// returns at once. A failed or timed out wait is not fatal: the frame may
181+// then show early, as it would on any such driver. A timeout is counted in
182+// the stats and warned about at most every fenceWarnEvery.
183+func (o *Output) waitFences(fences ...*os.File) {
184+ if !o.cpuFences {
185+ return
186+ }
187+ ctx := o.runContext
188+ if ctx == nil {
189+ ctx = context.Background()
190+ }
191+ limit := o.fenceLimit
192+ if limit == 0 {
193+ limit = fenceWaitLimit
194+ }
195+ ctx, cancel := context.WithTimeout(ctx, limit)
196+ defer cancel()
197+ for _, f := range fences {
198+ if f == nil {
199+ continue
200+ }
201+ err := syncfile.Wait(ctx, f)
202+ switch {
203+ case err == nil || errors.Is(err, context.Canceled):
204+ case errors.Is(err, context.DeadlineExceeded):
205+ o.flipStats.fenceTimeouts++
206+ if now := time.Now(); o.fenceWarnAt.IsZero() || now.Sub(o.fenceWarnAt) >= fenceWarnEvery {
207+ o.fenceWarnAt = now
208+ o.log.Warn().Str("connector", o.conn.name).Dur("limit", limit).Msg("frame fence did not signal in time; committing anyway")
209+ }
210+ default:
211+ o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("wait for frame fence")
212+ }
213+ }
214+}
@@ -0,0 +1,214 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/neferwl/internal/adapters/syncfile"
14+ "github.com/bnema/zerowrap"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// Old nvidia-drm drivers mishandle IN_FENCE_FD: the flip does not wait for
19+// the fence and shows a frame the GPU still draws
20+// (NVIDIA/open-gpu-kernel-modules#622, fixed in 560.35.3). On those the
21+// output waits for each fence on the CPU and commits without it.
22+
23+const (
24+ driverNVIDIA = "nvidia-drm"
25+ // nvidiaDRMVersionFile holds the loaded module's version.
26+ nvidiaDRMVersionFile = "/sys/module/nvidia_drm/version"
27+ // ioctlVersion is DRM_IOWR('d', 0x00, struct drm_version).
28+ ioctlVersion = 0xC0406400
29+)
30+
31+// nvidiaFenceFixed is the first nvidia-drm version that honours IN_FENCE_FD.
32+var nvidiaFenceFixed = [3]int{560, 35, 3}
33+
34+// drmVersion is struct drm_version.
35+type drmVersion struct {
36+ major, minor, patch int32
37+ nameLen uint64
38+ name unsafe.Pointer
39+ dateLen uint64
40+ date unsafe.Pointer
41+ descLen uint64
42+ desc unsafe.Pointer
43+}
44+
45+// driverName is the kernel driver of the DRM device fd (DRM_IOCTL_VERSION).
46+func driverName(fd int) (string, error) {
47+ var buf [64]byte
48+ v := drmVersion{nameLen: uint64(len(buf)), name: unsafe.Pointer(&buf[0])}
49+ if err := ioctl(fd, ioctlVersion, unsafe.Pointer(&v)); err != nil {
50+ return "", err
51+ }
52+ return string(buf[:min(int(v.nameLen), len(buf))]), nil
53+}
54+
55+// parseVersion reads a dotted version such as "560.35.03" (missing parts
56+// are 0). Parts are plain digits: no sign.
57+func parseVersion(s string) ([3]int, error) {
58+ var v [3]int
59+ parts := strings.Split(strings.TrimSpace(s), ".")
60+ if len(parts) > len(v) {
61+ return v, fmt.Errorf("version %q", s)
62+ }
63+ for i, p := range parts {
64+ n, err := strconv.ParseUint(p, 10, 31)
65+ if err != nil {
66+ return v, fmt.Errorf("version %q", s)
67+ }
68+ v[i] = int(n)
69+ }
70+ return v, nil
71+}
72+
73+// versionLess reports whether a < b.
74+func versionLess(a, b [3]int) bool {
75+ for i := range a {
76+ if a[i] != b[i] {
77+ return a[i] < b[i]
78+ }
79+ }
80+ return false
81+}
82+
83+// brokenInFence reports whether the driver may mishandle IN_FENCE_FD:
84+// nvidia-drm before 560.35.3, or whose version is unknown (version is the
85+// module version file's content, err its read error).
86+func brokenInFence(driver, version string, err error) bool {
87+ if driver != driverNVIDIA {
88+ return false
89+ }
90+ if err != nil {
91+ return true
92+ }
93+ v, perr := parseVersion(version)
94+ return perr != nil || versionLess(v, nvidiaFenceFixed)
95+}
96+
97+// fenceGate decides, once per card, whether commits carry no IN_FENCE_FD.
98+func fenceGate(fd int, log zerowrap.Logger) bool {
99+ driver, err := driverName(fd)
100+ if err != nil {
101+ log.Debug().Err(err).Msg("drm driver name unavailable")
102+ return false
103+ }
104+ var version string
105+ var verr error
106+ if driver == driverNVIDIA {
107+ b, rerr := os.ReadFile(nvidiaDRMVersionFile)
108+ version, verr = string(b), rerr
109+ }
110+ if !brokenInFence(driver, version, verr) {
111+ return false
112+ }
113+ log.Warn().Err(verr).Str("driver", driver).Str("version", strings.TrimSpace(version)).Msg("nvidia-drm version unknown or before 560.35.3 mishandles IN_FENCE_FD: waiting for frame fences on the CPU")
114+ return true
115+}
116+
117+// fenceProp sets IN_FENCE_FD on plane p to the fence, unless there is none
118+// or the fences are waited for on the CPU (waitFences).
119+func (o *Output) fenceProp(req *atomicReq, p *plane, fence *os.File) {
120+ if fence == nil || o.cpuFences {
121+ return
122+ }
123+ req.set(p.id, p.prop("IN_FENCE_FD"), uint64(fence.Fd()))
124+}
125+
126+// fenceWaitLimit bounds the CPU wait of one commit, whatever its number of
127+// fences.
128+const fenceWaitLimit = time.Second
129+
130+// fenceWarnEvery is the least time between two warnings about a fence wait
131+// that hit fenceWaitLimit.
132+const fenceWarnEvery = 10 * time.Second
133+
134+// fencePoll polls one fence without blocking. fn is built once, so polling
135+// allocates nothing; the output goroutine owns it.
136+type fencePoll struct {
137+ fds [1]unix.PollFd
138+ ready bool
139+ fn func(fd uintptr)
140+}
141+
142+func (p *fencePoll) init() {
143+ p.fn = func(fd uintptr) {
144+ p.fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
145+ n, err := unix.Poll(p.fds[:], 0)
146+ p.ready = err == nil && n > 0 && p.fds[0].Revents&unix.POLLIN != 0
147+ }
148+}
149+
150+// clientFenceReady reports whether a client's acquire fence lets its buffer
151+// be shown by a plane this frame. Where fences are waited for on the CPU, a
152+// client fence that has not signalled must not stall the output: the buffer
153+// is composed instead (the renderer waits for it on the GPU). Without CPU
154+// waits, and for no fence, it is ready; with them, a fence that cannot be
155+// polled is not ready.
156+func (o *Output) clientFenceReady(acquire *os.File) bool {
157+ if !o.cpuFences || acquire == nil {
158+ return true
159+ }
160+ if o.fencePoll.fn == nil {
161+ o.fencePoll.init()
162+ }
163+ raw, err := acquire.SyscallConn()
164+ if err != nil {
165+ return false
166+ }
167+ o.fencePoll.ready = false
168+ // Control keeps the descriptor open for the call: Wayland closing its
169+ // file meanwhile cannot hand us a reused number.
170+ if raw.Control(o.fencePoll.fn) != nil {
171+ return false
172+ }
173+ return o.fencePoll.ready
174+}
175+
176+// waitFences blocks until the fences signalled when the driver cannot wait
177+// for them in the commit, for at most fenceWaitLimit in all, or until the
178+// output stops. A client fence passed here (direct scanout) already
179+// signalled: planes take only such buffers (clientFenceReady), so it
180+// returns at once. A failed or timed out wait is not fatal: the frame may
181+// then show early, as it would on any such driver. A timeout is counted in
182+// the stats and warned about at most every fenceWarnEvery.
183+func (o *Output) waitFences(fences ...*os.File) {
184+ if !o.cpuFences {
185+ return
186+ }
187+ ctx := o.runContext
188+ if ctx == nil {
189+ ctx = context.Background()
190+ }
191+ limit := o.fenceLimit
192+ if limit == 0 {
193+ limit = fenceWaitLimit
194+ }
195+ ctx, cancel := context.WithTimeout(ctx, limit)
196+ defer cancel()
197+ for _, f := range fences {
198+ if f == nil {
199+ continue
200+ }
201+ err := syncfile.Wait(ctx, f)
202+ switch {
203+ case err == nil || errors.Is(err, context.Canceled):
204+ case errors.Is(err, context.DeadlineExceeded):
205+ o.flipStats.fenceTimeouts++
206+ if now := time.Now(); o.fenceWarnAt.IsZero() || now.Sub(o.fenceWarnAt) >= fenceWarnEvery {
207+ o.fenceWarnAt = now
208+ o.log.Warn().Str("connector", o.conn.name).Dur("limit", limit).Msg("frame fence did not signal in time; committing anyway")
209+ }
210+ default:
211+ o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("wait for frame fence")
212+ }
213+ }
214+}
A internal/adapters/drm/fencegate_test.go +283 -0
@@ -0,0 +1,283 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// pipeFence returns a pipe standing in for a sync file: its read end is
17+// readable (signalled) once the write end got a byte. Both ends close with
18+// the test.
19+func pipeFence(t *testing.T) (fence, signal *os.File) {
20+ t.Helper()
21+ r, w, err := os.Pipe()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ t.Cleanup(func() { r.Close(); w.Close() })
26+ return r, w
27+}
28+
29+func TestParseVersion(t *testing.T) {
30+ for _, tc := range []struct {
31+ in string
32+ want [3]int
33+ ok bool
34+ }{
35+ {"560.35.03\n", [3]int{560, 35, 3}, true},
36+ {"615.78.08", [3]int{615, 78, 8}, true},
37+ {"550.120", [3]int{550, 120, 0}, true},
38+ {"570", [3]int{570, 0, 0}, true},
39+ {"", [3]int{}, false},
40+ {"abc", [3]int{}, false},
41+ {"560.x.3", [3]int{}, false},
42+ {"1.2.3.4", [3]int{}, false},
43+ {"-1.2", [3]int{}, false},
44+ {"+560.35.3", [3]int{}, false},
45+ {"560.+35.3", [3]int{}, false},
46+ {"560.-5.3", [3]int{}, false},
47+ {"560..3", [3]int{}, false},
48+ {"99999999999.1.1", [3]int{}, false},
49+ } {
50+ got, err := parseVersion(tc.in)
51+ if (err == nil) != tc.ok || tc.ok && got != tc.want {
52+ t.Errorf("parseVersion(%q) = %v, %v", tc.in, got, err)
53+ }
54+ }
55+}
56+
57+// The gate opens for nvidia-drm before 560.35.3 or of unknown version, and
58+// for no other driver.
59+func TestBrokenInFence(t *testing.T) {
60+ for _, tc := range []struct {
61+ name, driver, version string
62+ err error
63+ want bool
64+ }{
65+ {"amd", "amdgpu", "", nil, false},
66+ {"intel", "i915", "garbage", nil, false},
67+ {"old nvidia", driverNVIDIA, "555.58.02", nil, true},
68+ {"one patch before", driverNVIDIA, "560.35.2", nil, true},
69+ {"minor before", driverNVIDIA, "560.34.99", nil, true},
70+ {"fixed", driverNVIDIA, "560.35.03\n", nil, false},
71+ {"patch after", driverNVIDIA, "560.35.5", nil, false},
72+ {"minor after", driverNVIDIA, "560.36.0", nil, false},
73+ {"major after", driverNVIDIA, "561.0.0", nil, false},
74+ {"newer", driverNVIDIA, "615.78.08", nil, false},
75+ {"unreadable", driverNVIDIA, "", errors.New("no such file"), true},
76+ {"unparsable", driverNVIDIA, "n/a", nil, true},
77+ } {
78+ if got := brokenInFence(tc.driver, tc.version, tc.err); got != tc.want {
79+ t.Errorf("%s: brokenInFence = %v, want %v", tc.name, got, tc.want)
80+ }
81+ }
82+}
83+
84+// The driver of a DRM device is read with DRM_IOCTL_VERSION. A render node
85+// answers it too: skips without one.
86+func TestDriverName(t *testing.T) {
87+ nodes, _ := filepath.Glob("/dev/dri/renderD*")
88+ if len(nodes) == 0 {
89+ t.Skip("no /dev/dri/renderD* node")
90+ }
91+ for _, n := range nodes {
92+ f, err := os.OpenFile(n, os.O_RDWR|unix.O_CLOEXEC, 0)
93+ if err != nil {
94+ continue
95+ }
96+ defer f.Close()
97+ name, err := driverName(int(f.Fd()))
98+ if err != nil || name == "" {
99+ t.Fatalf("driverName(%s) = %q, %v", n, name, err)
100+ }
101+ t.Logf("%s: %s", n, name)
102+ return
103+ }
104+ t.Skip("no render node can be opened")
105+}
106+
107+// With CPU waits no commit carries IN_FENCE_FD; otherwise the plane does.
108+func TestFenceProp(t *testing.T) {
109+ f, _ := pipeFence(t)
110+ for _, cpu := range []bool{false, true} {
111+ o, _, _ := testOutput(t)
112+ o.cpuFences = cpu
113+ req := &atomicReq{}
114+ o.fenceProp(req, o.primary, f)
115+ o.fenceProp(req, o.primary, nil)
116+ v, ok := req.value(o.primary.id, o.primary.prop("IN_FENCE_FD"))
117+ if ok == cpu || ok && v != uint64(f.Fd()) {
118+ t.Errorf("cpuFences=%v: IN_FENCE_FD %d, set %v", cpu, v, ok)
119+ }
120+ }
121+}
122+
123+// waitFences returns for a fence that signalled, for one that never will
124+// once the output stops, and at once where fences go to the kernel.
125+func TestWaitFences(t *testing.T) {
126+ returns := func(t *testing.T, o *Output, fences ...*os.File) {
127+ t.Helper()
128+ done := make(chan struct{})
129+ go func() { o.waitFences(fences...); close(done) }()
130+ select {
131+ case <-done:
132+ case <-time.After(30 * time.Second): // failure guard only
133+ t.Fatal("waitFences did not return")
134+ }
135+ }
136+ t.Run("signalled", func(t *testing.T) {
137+ o, _, _ := testOutput(t)
138+ o.cpuFences = true
139+ f, w := pipeFence(t)
140+ _, _ = w.Write([]byte{1})
141+ returns(t, o, nil, f)
142+ })
143+ t.Run("never signalled, output stopped", func(t *testing.T) {
144+ o, _, _ := testOutput(t)
145+ o.cpuFences = true
146+ ctx, cancel := context.WithCancel(t.Context())
147+ cancel()
148+ o.runContext = ctx
149+ f, _ := pipeFence(t)
150+ returns(t, o, f)
151+ if o.flipStats.fenceTimeouts != 0 {
152+ t.Fatal("a stopped output counted a fence timeout")
153+ }
154+ })
155+ t.Run("kernel waits", func(t *testing.T) {
156+ o, _, _ := testOutput(t)
157+ f, _ := pipeFence(t)
158+ returns(t, o, f)
159+ })
160+}
161+
162+// A fence wait that hit its limit counts in the stats, and warns at most
163+// once per fenceWarnEvery.
164+func TestWaitFencesTimeoutIsCountedAndRateLimited(t *testing.T) {
165+ o, _, _ := testOutput(t)
166+ o.cpuFences = true
167+ o.fenceLimit = time.Millisecond
168+ f, _ := pipeFence(t)
169+ o.waitFences(f)
170+ if o.flipStats.fenceTimeouts != 1 || o.fenceWarnAt.IsZero() {
171+ t.Fatalf("timeouts %d, warned at %v", o.flipStats.fenceTimeouts, o.fenceWarnAt)
172+ }
173+ first := o.fenceWarnAt
174+ o.waitFences(f)
175+ if o.flipStats.fenceTimeouts != 2 || !o.fenceWarnAt.Equal(first) {
176+ t.Fatalf("timeouts %d; warning not rate-limited (%v then %v)", o.flipStats.fenceTimeouts, first, o.fenceWarnAt)
177+ }
178+}
179+
180+// Without CPU fence waits every fence is ready; with them only a signalled
181+// client fence is, and one that cannot be polled is not.
182+func TestClientFenceReady(t *testing.T) {
183+ f, w := pipeFence(t)
184+ o, _, _ := testOutput(t)
185+ if !o.clientFenceReady(f) || !o.clientFenceReady(nil) {
186+ t.Fatal("fence not ready without CPU waits")
187+ }
188+ o.cpuFences = true
189+ if !o.clientFenceReady(nil) {
190+ t.Fatal("no fence must be ready")
191+ }
192+ if o.clientFenceReady(f) {
193+ t.Fatal("pending client fence ready")
194+ }
195+ _, _ = w.Write([]byte{1})
196+ if !o.clientFenceReady(f) {
197+ t.Fatal("signalled client fence not ready")
198+ }
199+ closed, cw := pipeFence(t)
200+ closed.Close()
201+ cw.Close()
202+ if o.clientFenceReady(closed) {
203+ t.Fatal("closed fence ready")
204+ }
205+}
206+
207+// Polling a fence allocates nothing.
208+func TestClientFenceReadyAllocatesNothing(t *testing.T) {
209+ f, _ := pipeFence(t)
210+ o, _, _ := testOutput(t)
211+ o.cpuFences = true
212+ o.clientFenceReady(f) // builds the poll closure
213+ if n := testing.AllocsPerRun(100, func() { o.clientFenceReady(f) }); n != 0 {
214+ t.Fatalf("%v allocations per poll", n)
215+ }
216+}
217+
218+// With CPU waits a client buffer whose fence is pending is composed, on the
219+// primary plane and on the overlay alike: neither plane waits for it. Once
220+// the fence signalled the same frame uses the plane.
221+func TestPendingClientFenceComposes(t *testing.T) {
222+ for _, tc := range []struct {
223+ name string
224+ reason func(o *Output) string
225+ // decide returns whether the plane took the buffer.
226+ decide func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool
227+ }{
228+ {"primary scanout", func(o *Output) string { return o.reason }, func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool {
229+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
230+ s, c := fullscreenScene()
231+ content := c[1]
232+ content.Acquire = fence
233+ fb, _, _ := o.scanoutFrame(s, map[ports.WindowID]ports.SurfaceContent{1: content})
234+ return fb != 0
235+ }},
236+ {"overlay", func(o *Output) string { return o.overlayReason }, func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool {
237+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
238+ s, c := overlayScene()
239+ content := c[2]
240+ content.Acquire = fence
241+ ov, _ := o.overlayFrame(s, map[ports.WindowID]ports.SurfaceContent{2: content})
242+ ov.close()
243+ return ov.fb != 0
244+ }},
245+ } {
246+ t.Run(tc.name, func(t *testing.T) {
247+ o, k, _ := overlayOutput(t)
248+ k.EXPECT().addFB(mock.Anything, mock.Anything).Return(60, nil).Maybe()
249+ o.cpuFences = true
250+ fence, w := pipeFence(t)
251+ if tc.decide(t, o, k, fence) || tc.reason(o) != "fence_pending" {
252+ t.Fatalf("pending fence: plane took the buffer or reason %q", tc.reason(o))
253+ }
254+ _, _ = w.Write([]byte{1})
255+ if !tc.decide(t, o, k, fence) {
256+ t.Fatalf("signalled fence: reason %q", tc.reason(o))
257+ }
258+ })
259+ }
260+}
261+
262+// A commit that is retried (here: VRR refused, then the same frame without
263+// it) waits for its fence once, not once per attempt.
264+func TestCommitWaitsForItsFenceOnce(t *testing.T) {
265+ o, _, commits := testOutput(t, unix.EINVAL)
266+ o.cpuFences = true
267+ o.fenceLimit = time.Millisecond
268+ fence, _ := pipeFence(t)
269+ if err := o.commitFrame(70, fence, false, true, pendingFrame{}); err != nil {
270+ t.Fatal(err)
271+ }
272+ if len(*commits) < 2 {
273+ t.Fatalf("%d commits: no retry happened", len(*commits))
274+ }
275+ if o.flipStats.fenceTimeouts != 1 {
276+ t.Fatalf("%d fence waits for %d commits, want one", o.flipStats.fenceTimeouts, len(*commits))
277+ }
278+ for i, c := range *commits {
279+ if _, ok := c.req.value(tPrimary, pFence); ok {
280+ t.Fatalf("commit %d carries IN_FENCE_FD with CPU waits", i)
281+ }
282+ }
283+}
@@ -0,0 +1,283 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// pipeFence returns a pipe standing in for a sync file: its read end is
17+// readable (signalled) once the write end got a byte. Both ends close with
18+// the test.
19+func pipeFence(t *testing.T) (fence, signal *os.File) {
20+ t.Helper()
21+ r, w, err := os.Pipe()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ t.Cleanup(func() { r.Close(); w.Close() })
26+ return r, w
27+}
28+
29+func TestParseVersion(t *testing.T) {
30+ for _, tc := range []struct {
31+ in string
32+ want [3]int
33+ ok bool
34+ }{
35+ {"560.35.03\n", [3]int{560, 35, 3}, true},
36+ {"615.78.08", [3]int{615, 78, 8}, true},
37+ {"550.120", [3]int{550, 120, 0}, true},
38+ {"570", [3]int{570, 0, 0}, true},
39+ {"", [3]int{}, false},
40+ {"abc", [3]int{}, false},
41+ {"560.x.3", [3]int{}, false},
42+ {"1.2.3.4", [3]int{}, false},
43+ {"-1.2", [3]int{}, false},
44+ {"+560.35.3", [3]int{}, false},
45+ {"560.+35.3", [3]int{}, false},
46+ {"560.-5.3", [3]int{}, false},
47+ {"560..3", [3]int{}, false},
48+ {"99999999999.1.1", [3]int{}, false},
49+ } {
50+ got, err := parseVersion(tc.in)
51+ if (err == nil) != tc.ok || tc.ok && got != tc.want {
52+ t.Errorf("parseVersion(%q) = %v, %v", tc.in, got, err)
53+ }
54+ }
55+}
56+
57+// The gate opens for nvidia-drm before 560.35.3 or of unknown version, and
58+// for no other driver.
59+func TestBrokenInFence(t *testing.T) {
60+ for _, tc := range []struct {
61+ name, driver, version string
62+ err error
63+ want bool
64+ }{
65+ {"amd", "amdgpu", "", nil, false},
66+ {"intel", "i915", "garbage", nil, false},
67+ {"old nvidia", driverNVIDIA, "555.58.02", nil, true},
68+ {"one patch before", driverNVIDIA, "560.35.2", nil, true},
69+ {"minor before", driverNVIDIA, "560.34.99", nil, true},
70+ {"fixed", driverNVIDIA, "560.35.03\n", nil, false},
71+ {"patch after", driverNVIDIA, "560.35.5", nil, false},
72+ {"minor after", driverNVIDIA, "560.36.0", nil, false},
73+ {"major after", driverNVIDIA, "561.0.0", nil, false},
74+ {"newer", driverNVIDIA, "615.78.08", nil, false},
75+ {"unreadable", driverNVIDIA, "", errors.New("no such file"), true},
76+ {"unparsable", driverNVIDIA, "n/a", nil, true},
77+ } {
78+ if got := brokenInFence(tc.driver, tc.version, tc.err); got != tc.want {
79+ t.Errorf("%s: brokenInFence = %v, want %v", tc.name, got, tc.want)
80+ }
81+ }
82+}
83+
84+// The driver of a DRM device is read with DRM_IOCTL_VERSION. A render node
85+// answers it too: skips without one.
86+func TestDriverName(t *testing.T) {
87+ nodes, _ := filepath.Glob("/dev/dri/renderD*")
88+ if len(nodes) == 0 {
89+ t.Skip("no /dev/dri/renderD* node")
90+ }
91+ for _, n := range nodes {
92+ f, err := os.OpenFile(n, os.O_RDWR|unix.O_CLOEXEC, 0)
93+ if err != nil {
94+ continue
95+ }
96+ defer f.Close()
97+ name, err := driverName(int(f.Fd()))
98+ if err != nil || name == "" {
99+ t.Fatalf("driverName(%s) = %q, %v", n, name, err)
100+ }
101+ t.Logf("%s: %s", n, name)
102+ return
103+ }
104+ t.Skip("no render node can be opened")
105+}
106+
107+// With CPU waits no commit carries IN_FENCE_FD; otherwise the plane does.
108+func TestFenceProp(t *testing.T) {
109+ f, _ := pipeFence(t)
110+ for _, cpu := range []bool{false, true} {
111+ o, _, _ := testOutput(t)
112+ o.cpuFences = cpu
113+ req := &atomicReq{}
114+ o.fenceProp(req, o.primary, f)
115+ o.fenceProp(req, o.primary, nil)
116+ v, ok := req.value(o.primary.id, o.primary.prop("IN_FENCE_FD"))
117+ if ok == cpu || ok && v != uint64(f.Fd()) {
118+ t.Errorf("cpuFences=%v: IN_FENCE_FD %d, set %v", cpu, v, ok)
119+ }
120+ }
121+}
122+
123+// waitFences returns for a fence that signalled, for one that never will
124+// once the output stops, and at once where fences go to the kernel.
125+func TestWaitFences(t *testing.T) {
126+ returns := func(t *testing.T, o *Output, fences ...*os.File) {
127+ t.Helper()
128+ done := make(chan struct{})
129+ go func() { o.waitFences(fences...); close(done) }()
130+ select {
131+ case <-done:
132+ case <-time.After(30 * time.Second): // failure guard only
133+ t.Fatal("waitFences did not return")
134+ }
135+ }
136+ t.Run("signalled", func(t *testing.T) {
137+ o, _, _ := testOutput(t)
138+ o.cpuFences = true
139+ f, w := pipeFence(t)
140+ _, _ = w.Write([]byte{1})
141+ returns(t, o, nil, f)
142+ })
143+ t.Run("never signalled, output stopped", func(t *testing.T) {
144+ o, _, _ := testOutput(t)
145+ o.cpuFences = true
146+ ctx, cancel := context.WithCancel(t.Context())
147+ cancel()
148+ o.runContext = ctx
149+ f, _ := pipeFence(t)
150+ returns(t, o, f)
151+ if o.flipStats.fenceTimeouts != 0 {
152+ t.Fatal("a stopped output counted a fence timeout")
153+ }
154+ })
155+ t.Run("kernel waits", func(t *testing.T) {
156+ o, _, _ := testOutput(t)
157+ f, _ := pipeFence(t)
158+ returns(t, o, f)
159+ })
160+}
161+
162+// A fence wait that hit its limit counts in the stats, and warns at most
163+// once per fenceWarnEvery.
164+func TestWaitFencesTimeoutIsCountedAndRateLimited(t *testing.T) {
165+ o, _, _ := testOutput(t)
166+ o.cpuFences = true
167+ o.fenceLimit = time.Millisecond
168+ f, _ := pipeFence(t)
169+ o.waitFences(f)
170+ if o.flipStats.fenceTimeouts != 1 || o.fenceWarnAt.IsZero() {
171+ t.Fatalf("timeouts %d, warned at %v", o.flipStats.fenceTimeouts, o.fenceWarnAt)
172+ }
173+ first := o.fenceWarnAt
174+ o.waitFences(f)
175+ if o.flipStats.fenceTimeouts != 2 || !o.fenceWarnAt.Equal(first) {
176+ t.Fatalf("timeouts %d; warning not rate-limited (%v then %v)", o.flipStats.fenceTimeouts, first, o.fenceWarnAt)
177+ }
178+}
179+
180+// Without CPU fence waits every fence is ready; with them only a signalled
181+// client fence is, and one that cannot be polled is not.
182+func TestClientFenceReady(t *testing.T) {
183+ f, w := pipeFence(t)
184+ o, _, _ := testOutput(t)
185+ if !o.clientFenceReady(f) || !o.clientFenceReady(nil) {
186+ t.Fatal("fence not ready without CPU waits")
187+ }
188+ o.cpuFences = true
189+ if !o.clientFenceReady(nil) {
190+ t.Fatal("no fence must be ready")
191+ }
192+ if o.clientFenceReady(f) {
193+ t.Fatal("pending client fence ready")
194+ }
195+ _, _ = w.Write([]byte{1})
196+ if !o.clientFenceReady(f) {
197+ t.Fatal("signalled client fence not ready")
198+ }
199+ closed, cw := pipeFence(t)
200+ closed.Close()
201+ cw.Close()
202+ if o.clientFenceReady(closed) {
203+ t.Fatal("closed fence ready")
204+ }
205+}
206+
207+// Polling a fence allocates nothing.
208+func TestClientFenceReadyAllocatesNothing(t *testing.T) {
209+ f, _ := pipeFence(t)
210+ o, _, _ := testOutput(t)
211+ o.cpuFences = true
212+ o.clientFenceReady(f) // builds the poll closure
213+ if n := testing.AllocsPerRun(100, func() { o.clientFenceReady(f) }); n != 0 {
214+ t.Fatalf("%v allocations per poll", n)
215+ }
216+}
217+
218+// With CPU waits a client buffer whose fence is pending is composed, on the
219+// primary plane and on the overlay alike: neither plane waits for it. Once
220+// the fence signalled the same frame uses the plane.
221+func TestPendingClientFenceComposes(t *testing.T) {
222+ for _, tc := range []struct {
223+ name string
224+ reason func(o *Output) string
225+ // decide returns whether the plane took the buffer.
226+ decide func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool
227+ }{
228+ {"primary scanout", func(o *Output) string { return o.reason }, func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool {
229+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
230+ s, c := fullscreenScene()
231+ content := c[1]
232+ content.Acquire = fence
233+ fb, _, _ := o.scanoutFrame(s, map[ports.WindowID]ports.SurfaceContent{1: content})
234+ return fb != 0
235+ }},
236+ {"overlay", func(o *Output) string { return o.overlayReason }, func(t *testing.T, o *Output, k *mockkms, fence *os.File) bool {
237+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
238+ s, c := overlayScene()
239+ content := c[2]
240+ content.Acquire = fence
241+ ov, _ := o.overlayFrame(s, map[ports.WindowID]ports.SurfaceContent{2: content})
242+ ov.close()
243+ return ov.fb != 0
244+ }},
245+ } {
246+ t.Run(tc.name, func(t *testing.T) {
247+ o, k, _ := overlayOutput(t)
248+ k.EXPECT().addFB(mock.Anything, mock.Anything).Return(60, nil).Maybe()
249+ o.cpuFences = true
250+ fence, w := pipeFence(t)
251+ if tc.decide(t, o, k, fence) || tc.reason(o) != "fence_pending" {
252+ t.Fatalf("pending fence: plane took the buffer or reason %q", tc.reason(o))
253+ }
254+ _, _ = w.Write([]byte{1})
255+ if !tc.decide(t, o, k, fence) {
256+ t.Fatalf("signalled fence: reason %q", tc.reason(o))
257+ }
258+ })
259+ }
260+}
261+
262+// A commit that is retried (here: VRR refused, then the same frame without
263+// it) waits for its fence once, not once per attempt.
264+func TestCommitWaitsForItsFenceOnce(t *testing.T) {
265+ o, _, commits := testOutput(t, unix.EINVAL)
266+ o.cpuFences = true
267+ o.fenceLimit = time.Millisecond
268+ fence, _ := pipeFence(t)
269+ if err := o.commitFrame(70, fence, false, true, pendingFrame{}); err != nil {
270+ t.Fatal(err)
271+ }
272+ if len(*commits) < 2 {
273+ t.Fatalf("%d commits: no retry happened", len(*commits))
274+ }
275+ if o.flipStats.fenceTimeouts != 1 {
276+ t.Fatalf("%d fence waits for %d commits, want one", o.flipStats.fenceTimeouts, len(*commits))
277+ }
278+ for i, c := range *commits {
279+ if _, ok := c.req.value(tPrimary, pFence); ok {
280+ t.Fatalf("commit %d carries IN_FENCE_FD with CPU waits", i)
281+ }
282+ }
283+}
M internal/adapters/drm/flip_stats.go +3 -0
@@ -30,6 +30,9 @@ type flipStats struct {
3030 // stateCarried counts deferred state changes a frame carried instead.
3131 // Many state commits while a video plays point at a lost vblank each.
3232 stateCommits, stateCarried int
33+ // fenceTimeouts counts commits whose CPU fence wait hit its limit
34+ // (fencegate.go).
35+ fenceTimeouts int
3336 }
3437
3538 // nominalPeriod is the time between two vblanks at the mode's refresh
@@ -30,6 +30,9 @@ type flipStats struct {
30 // stateCarried counts deferred state changes a frame carried instead.30 // stateCarried counts deferred state changes a frame carried instead.
31 // Many state commits while a video plays point at a lost vblank each.31 // Many state commits while a video plays point at a lost vblank each.
32 stateCommits, stateCarried int32 stateCommits, stateCarried int
33+ // fenceTimeouts counts commits whose CPU fence wait hit its limit
34+ // (fencegate.go).
35+ fenceTimeouts int
33 }36 }
34 37
35 // nominalPeriod is the time between two vblanks at the mode's refresh38 // nominalPeriod is the time between two vblanks at the mode's refresh
A internal/adapters/drm/gbm/gbm.go +276 -0
@@ -0,0 +1,276 @@
1+// Package gbm allocates dmabuf images with libgbm, loaded at runtime with
2+// purego. Display drivers know best how to lay out a buffer they will scan
3+// out (NVIDIA's block-linear layouts in particular), so the DRM output asks
4+// GBM for its images and the renderer imports them.
5+package gbm
6+
7+import (
8+ "errors"
9+ "fmt"
10+ "os"
11+ "runtime"
12+ "slices"
13+ "sync"
14+ "unsafe"
15+
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/purego"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Usage is a set of GBM_BO_USE_* flags.
22+type Usage uint32
23+
24+const (
25+ // UseScanout (GBM_BO_USE_SCANOUT): the buffer can be shown by KMS.
26+ UseScanout Usage = 1 << 0
27+ // UseRendering (GBM_BO_USE_RENDERING): a GPU can render into it.
28+ UseRendering Usage = 1 << 2
29+ // useLinear (GBM_BO_USE_LINEAR): the layout is linear.
30+ useLinear Usage = 1 << 4
31+)
32+
33+const (
34+ // modLinear is DRM_FORMAT_MOD_LINEAR.
35+ modLinear = 0
36+ // modInvalid is DRM_FORMAT_MOD_INVALID: an implicit layout.
37+ modInvalid = 1<<56 - 1
38+ // maxPlanes is the DRM limit of planes in a framebuffer.
39+ maxPlanes = 4
40+)
41+
42+var (
43+ loadOnce sync.Once
44+ loadErr error
45+
46+ createDevice func(fd int32) uintptr
47+ destroyDevice func(dev uintptr)
48+ createWithModifiers2 func(dev uintptr, w, h, format uint32, mods unsafe.Pointer, count uint32, flags uint32) uintptr
49+ createPlain func(dev uintptr, w, h, format uint32, flags uint32) uintptr
50+ boModifier func(bo uintptr) uint64
51+ boPlaneCount func(bo uintptr) int32
52+ boFDForPlane func(bo uintptr, plane int32) int32
53+ boStrideForPlane func(bo uintptr, plane int32) uint32
54+ boOffset func(bo uintptr, plane int32) uint32
55+ boDestroy func(bo uintptr)
56+)
57+
58+// load opens libgbm once. Without it, or with one that lacks a function
59+// below (gbm_bo_create_with_modifiers2 and gbm_bo_get_fd_for_plane came with
60+// Mesa 21.1), the allocator is unavailable and callers fall back to another
61+// way to make images.
62+func load() error {
63+ loadOnce.Do(func() {
64+ lib, err := purego.Dlopen("libgbm.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
65+ if err != nil {
66+ loadErr = fmt.Errorf("open libgbm.so.1: %w", err)
67+ return
68+ }
69+ defer func() {
70+ if r := recover(); r != nil {
71+ loadErr = fmt.Errorf("libgbm: %v", r)
72+ }
73+ }()
74+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, name) }
75+ reg(&createDevice, "gbm_create_device")
76+ reg(&destroyDevice, "gbm_device_destroy")
77+ reg(&createWithModifiers2, "gbm_bo_create_with_modifiers2")
78+ reg(&createPlain, "gbm_bo_create")
79+ reg(&boModifier, "gbm_bo_get_modifier")
80+ reg(&boPlaneCount, "gbm_bo_get_plane_count")
81+ reg(&boFDForPlane, "gbm_bo_get_fd_for_plane")
82+ reg(&boStrideForPlane, "gbm_bo_get_stride_for_plane")
83+ reg(&boOffset, "gbm_bo_get_offset")
84+ reg(&boDestroy, "gbm_bo_destroy")
85+ })
86+ return loadErr
87+}
88+
89+// Device is a GBM device on a duplicate of a DRM device fd. Close its
90+// buffers before the device.
91+type Device struct {
92+ dev uintptr
93+ fd int
94+}
95+
96+// Open creates a GBM device on a duplicate of fd, which stays the caller's.
97+func Open(fd int) (*Device, error) {
98+ if err := load(); err != nil {
99+ return nil, err
100+ }
101+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
102+ if err != nil {
103+ return nil, fmt.Errorf("duplicate drm fd: %w", err)
104+ }
105+ dev := createDevice(int32(dup))
106+ if dev == 0 {
107+ _ = unix.Close(dup)
108+ return nil, errors.New("gbm_create_device failed")
109+ }
110+ return &Device{dev: dev, fd: dup}, nil
111+}
112+
113+// Close destroys the device and its fd.
114+func (d *Device) Close() {
115+ if d == nil || d.dev == 0 {
116+ return
117+ }
118+ destroyDevice(d.dev)
119+ _ = unix.Close(d.fd)
120+ d.dev = 0
121+}
122+
123+// Buffer is an allocated image. It owns one fd per plane and the GBM
124+// object, kept alive for as long as the image is used.
125+type Buffer struct {
126+ bo uintptr
127+ width, height int
128+ format uint32
129+ modifier uint64
130+ planes []plane
131+}
132+
133+type plane struct {
134+ fd int
135+ offset, stride uint32
136+}
137+
138+// Create allocates an image whose modifier is one of modifiers (a
139+// non-empty list). A list holding only linear falls back to a plain
140+// linear allocation when GBM refuses the modifier call, and accepts an
141+// implicit layout (DRM_FORMAT_MOD_INVALID) as linear. The
142+// result must be one memory object, because the renderer imports one
143+// dmabuf for all planes.
144+func (d *Device) Create(width, height int, format uint32, modifiers []uint64, usage Usage) (*Buffer, error) {
145+ if d == nil || d.dev == 0 {
146+ return nil, errors.New("gbm device closed")
147+ }
148+ if width <= 0 || height <= 0 || len(modifiers) == 0 {
149+ return nil, fmt.Errorf("gbm: bad request %dx%d with %d modifiers", width, height, len(modifiers))
150+ }
151+ bo := createWithModifiers2(d.dev, uint32(width), uint32(height), format, unsafe.Pointer(&modifiers[0]), uint32(len(modifiers)), uint32(usage))
152+ runtime.KeepAlive(modifiers)
153+ onlyLinear := len(modifiers) == 1 && modifiers[0] == modLinear
154+ if bo == 0 && onlyLinear {
155+ bo = createPlain(d.dev, uint32(width), uint32(height), format, uint32(usage|useLinear))
156+ }
157+ if bo == 0 {
158+ return nil, fmt.Errorf("gbm: no %dx%d buffer of format %#x with modifiers %#x", width, height, format, modifiers)
159+ }
160+ b, err := readBuffer(bo, width, height, format, acceptedModifiers(modifiers))
161+ if err != nil {
162+ boDestroy(bo)
163+ return nil, err
164+ }
165+ if onlyLinear && b.modifier == modInvalid {
166+ b.modifier = modLinear
167+ }
168+ return b, nil
169+}
170+
171+// acceptedModifiers is what an allocation may report for a request: the
172+// request itself, plus an implicit layout when only linear was asked for.
173+func acceptedModifiers(requested []uint64) []uint64 {
174+ if len(requested) == 1 && requested[0] == modLinear {
175+ return []uint64{modLinear, modInvalid}
176+ }
177+ return requested
178+}
179+
180+// readBuffer reads the layout of bo and validates it against the request.
181+// It does not destroy bo on error.
182+func readBuffer(bo uintptr, width, height int, format uint32, modifiers []uint64) (*Buffer, error) {
183+ b := &Buffer{bo: bo, width: width, height: height, format: format, modifier: boModifier(bo)}
184+ if err := checkModifier(modifiers, b.modifier); err != nil {
185+ return nil, err
186+ }
187+ n := int(boPlaneCount(bo))
188+ if n < 1 || n > maxPlanes {
189+ return nil, fmt.Errorf("gbm: buffer has %d planes", n)
190+ }
191+ for i := range n {
192+ fd := boFDForPlane(bo, int32(i))
193+ if fd < 0 {
194+ b.closeFDs()
195+ return nil, fmt.Errorf("gbm: no fd for plane %d", i)
196+ }
197+ b.planes = append(b.planes, plane{fd: int(fd), offset: boOffset(bo, int32(i)), stride: boStrideForPlane(bo, int32(i))})
198+ }
199+ fds := make([]int, len(b.planes))
200+ for i, p := range b.planes {
201+ fds[i] = p.fd
202+ }
203+ if err := checkOneObject(fds); err != nil {
204+ b.closeFDs()
205+ return nil, err
206+ }
207+ b.bo = bo
208+ return b, nil
209+}
210+
211+// checkModifier fails when the allocator returned a modifier that was not
212+// asked for (including DRM_FORMAT_MOD_INVALID, an implicit layout the
213+// renderer cannot import).
214+func checkModifier(requested []uint64, got uint64) error {
215+ if !slices.Contains(requested, got) {
216+ return fmt.Errorf("gbm: modifier %#x not in the requested list %#x", got, requested)
217+ }
218+ return nil
219+}
220+
221+// checkOneObject fails when the plane fds are not all the same dmabuf:
222+// disjoint planes cannot be imported as one memory object.
223+func checkOneObject(fds []int) error {
224+ var first unix.Stat_t
225+ for i, fd := range fds {
226+ var st unix.Stat_t
227+ if err := unix.Fstat(fd, &st); err != nil {
228+ return fmt.Errorf("gbm: stat plane %d: %w", i, err)
229+ }
230+ if i == 0 {
231+ first = st
232+ continue
233+ }
234+ if st.Dev != first.Dev || st.Ino != first.Ino {
235+ return fmt.Errorf("gbm: plane %d is a different dmabuf than plane 0", i)
236+ }
237+ }
238+ return nil
239+}
240+
241+func (b *Buffer) closeFDs() {
242+ for _, p := range b.planes {
243+ _ = unix.Close(p.fd)
244+ }
245+ b.planes = nil
246+}
247+
248+// DMABuf returns the image as a dmabuf with fresh descriptors (the caller
249+// closes them); the buffer keeps its own.
250+func (b *Buffer) DMABuf() (ports.DMABuf, error) {
251+ if b == nil || b.bo == 0 {
252+ return ports.DMABuf{}, errors.New("gbm buffer closed")
253+ }
254+ out := ports.DMABuf{Width: b.width, Height: b.height, Format: b.format, Modifier: b.modifier}
255+ for _, p := range b.planes {
256+ fd, err := unix.FcntlInt(uintptr(p.fd), unix.F_DUPFD_CLOEXEC, 0)
257+ if err != nil {
258+ for _, q := range out.Planes {
259+ _ = q.File.Close()
260+ }
261+ return ports.DMABuf{}, fmt.Errorf("duplicate gbm plane fd: %w", err)
262+ }
263+ out.Planes = append(out.Planes, ports.DMABufPlane{File: os.NewFile(uintptr(fd), "neferwl-gbm"), Offset: p.offset, Stride: p.stride})
264+ }
265+ return out, nil
266+}
267+
268+// Close frees the buffer and its descriptors.
269+func (b *Buffer) Close() {
270+ if b == nil || b.bo == 0 {
271+ return
272+ }
273+ b.closeFDs()
274+ boDestroy(b.bo)
275+ b.bo = 0
276+}
@@ -0,0 +1,276 @@
1+// Package gbm allocates dmabuf images with libgbm, loaded at runtime with
2+// purego. Display drivers know best how to lay out a buffer they will scan
3+// out (NVIDIA's block-linear layouts in particular), so the DRM output asks
4+// GBM for its images and the renderer imports them.
5+package gbm
6+
7+import (
8+ "errors"
9+ "fmt"
10+ "os"
11+ "runtime"
12+ "slices"
13+ "sync"
14+ "unsafe"
15+
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/purego"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Usage is a set of GBM_BO_USE_* flags.
22+type Usage uint32
23+
24+const (
25+ // UseScanout (GBM_BO_USE_SCANOUT): the buffer can be shown by KMS.
26+ UseScanout Usage = 1 << 0
27+ // UseRendering (GBM_BO_USE_RENDERING): a GPU can render into it.
28+ UseRendering Usage = 1 << 2
29+ // useLinear (GBM_BO_USE_LINEAR): the layout is linear.
30+ useLinear Usage = 1 << 4
31+)
32+
33+const (
34+ // modLinear is DRM_FORMAT_MOD_LINEAR.
35+ modLinear = 0
36+ // modInvalid is DRM_FORMAT_MOD_INVALID: an implicit layout.
37+ modInvalid = 1<<56 - 1
38+ // maxPlanes is the DRM limit of planes in a framebuffer.
39+ maxPlanes = 4
40+)
41+
42+var (
43+ loadOnce sync.Once
44+ loadErr error
45+
46+ createDevice func(fd int32) uintptr
47+ destroyDevice func(dev uintptr)
48+ createWithModifiers2 func(dev uintptr, w, h, format uint32, mods unsafe.Pointer, count uint32, flags uint32) uintptr
49+ createPlain func(dev uintptr, w, h, format uint32, flags uint32) uintptr
50+ boModifier func(bo uintptr) uint64
51+ boPlaneCount func(bo uintptr) int32
52+ boFDForPlane func(bo uintptr, plane int32) int32
53+ boStrideForPlane func(bo uintptr, plane int32) uint32
54+ boOffset func(bo uintptr, plane int32) uint32
55+ boDestroy func(bo uintptr)
56+)
57+
58+// load opens libgbm once. Without it, or with one that lacks a function
59+// below (gbm_bo_create_with_modifiers2 and gbm_bo_get_fd_for_plane came with
60+// Mesa 21.1), the allocator is unavailable and callers fall back to another
61+// way to make images.
62+func load() error {
63+ loadOnce.Do(func() {
64+ lib, err := purego.Dlopen("libgbm.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
65+ if err != nil {
66+ loadErr = fmt.Errorf("open libgbm.so.1: %w", err)
67+ return
68+ }
69+ defer func() {
70+ if r := recover(); r != nil {
71+ loadErr = fmt.Errorf("libgbm: %v", r)
72+ }
73+ }()
74+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, name) }
75+ reg(&createDevice, "gbm_create_device")
76+ reg(&destroyDevice, "gbm_device_destroy")
77+ reg(&createWithModifiers2, "gbm_bo_create_with_modifiers2")
78+ reg(&createPlain, "gbm_bo_create")
79+ reg(&boModifier, "gbm_bo_get_modifier")
80+ reg(&boPlaneCount, "gbm_bo_get_plane_count")
81+ reg(&boFDForPlane, "gbm_bo_get_fd_for_plane")
82+ reg(&boStrideForPlane, "gbm_bo_get_stride_for_plane")
83+ reg(&boOffset, "gbm_bo_get_offset")
84+ reg(&boDestroy, "gbm_bo_destroy")
85+ })
86+ return loadErr
87+}
88+
89+// Device is a GBM device on a duplicate of a DRM device fd. Close its
90+// buffers before the device.
91+type Device struct {
92+ dev uintptr
93+ fd int
94+}
95+
96+// Open creates a GBM device on a duplicate of fd, which stays the caller's.
97+func Open(fd int) (*Device, error) {
98+ if err := load(); err != nil {
99+ return nil, err
100+ }
101+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
102+ if err != nil {
103+ return nil, fmt.Errorf("duplicate drm fd: %w", err)
104+ }
105+ dev := createDevice(int32(dup))
106+ if dev == 0 {
107+ _ = unix.Close(dup)
108+ return nil, errors.New("gbm_create_device failed")
109+ }
110+ return &Device{dev: dev, fd: dup}, nil
111+}
112+
113+// Close destroys the device and its fd.
114+func (d *Device) Close() {
115+ if d == nil || d.dev == 0 {
116+ return
117+ }
118+ destroyDevice(d.dev)
119+ _ = unix.Close(d.fd)
120+ d.dev = 0
121+}
122+
123+// Buffer is an allocated image. It owns one fd per plane and the GBM
124+// object, kept alive for as long as the image is used.
125+type Buffer struct {
126+ bo uintptr
127+ width, height int
128+ format uint32
129+ modifier uint64
130+ planes []plane
131+}
132+
133+type plane struct {
134+ fd int
135+ offset, stride uint32
136+}
137+
138+// Create allocates an image whose modifier is one of modifiers (a
139+// non-empty list). A list holding only linear falls back to a plain
140+// linear allocation when GBM refuses the modifier call, and accepts an
141+// implicit layout (DRM_FORMAT_MOD_INVALID) as linear. The
142+// result must be one memory object, because the renderer imports one
143+// dmabuf for all planes.
144+func (d *Device) Create(width, height int, format uint32, modifiers []uint64, usage Usage) (*Buffer, error) {
145+ if d == nil || d.dev == 0 {
146+ return nil, errors.New("gbm device closed")
147+ }
148+ if width <= 0 || height <= 0 || len(modifiers) == 0 {
149+ return nil, fmt.Errorf("gbm: bad request %dx%d with %d modifiers", width, height, len(modifiers))
150+ }
151+ bo := createWithModifiers2(d.dev, uint32(width), uint32(height), format, unsafe.Pointer(&modifiers[0]), uint32(len(modifiers)), uint32(usage))
152+ runtime.KeepAlive(modifiers)
153+ onlyLinear := len(modifiers) == 1 && modifiers[0] == modLinear
154+ if bo == 0 && onlyLinear {
155+ bo = createPlain(d.dev, uint32(width), uint32(height), format, uint32(usage|useLinear))
156+ }
157+ if bo == 0 {
158+ return nil, fmt.Errorf("gbm: no %dx%d buffer of format %#x with modifiers %#x", width, height, format, modifiers)
159+ }
160+ b, err := readBuffer(bo, width, height, format, acceptedModifiers(modifiers))
161+ if err != nil {
162+ boDestroy(bo)
163+ return nil, err
164+ }
165+ if onlyLinear && b.modifier == modInvalid {
166+ b.modifier = modLinear
167+ }
168+ return b, nil
169+}
170+
171+// acceptedModifiers is what an allocation may report for a request: the
172+// request itself, plus an implicit layout when only linear was asked for.
173+func acceptedModifiers(requested []uint64) []uint64 {
174+ if len(requested) == 1 && requested[0] == modLinear {
175+ return []uint64{modLinear, modInvalid}
176+ }
177+ return requested
178+}
179+
180+// readBuffer reads the layout of bo and validates it against the request.
181+// It does not destroy bo on error.
182+func readBuffer(bo uintptr, width, height int, format uint32, modifiers []uint64) (*Buffer, error) {
183+ b := &Buffer{bo: bo, width: width, height: height, format: format, modifier: boModifier(bo)}
184+ if err := checkModifier(modifiers, b.modifier); err != nil {
185+ return nil, err
186+ }
187+ n := int(boPlaneCount(bo))
188+ if n < 1 || n > maxPlanes {
189+ return nil, fmt.Errorf("gbm: buffer has %d planes", n)
190+ }
191+ for i := range n {
192+ fd := boFDForPlane(bo, int32(i))
193+ if fd < 0 {
194+ b.closeFDs()
195+ return nil, fmt.Errorf("gbm: no fd for plane %d", i)
196+ }
197+ b.planes = append(b.planes, plane{fd: int(fd), offset: boOffset(bo, int32(i)), stride: boStrideForPlane(bo, int32(i))})
198+ }
199+ fds := make([]int, len(b.planes))
200+ for i, p := range b.planes {
201+ fds[i] = p.fd
202+ }
203+ if err := checkOneObject(fds); err != nil {
204+ b.closeFDs()
205+ return nil, err
206+ }
207+ b.bo = bo
208+ return b, nil
209+}
210+
211+// checkModifier fails when the allocator returned a modifier that was not
212+// asked for (including DRM_FORMAT_MOD_INVALID, an implicit layout the
213+// renderer cannot import).
214+func checkModifier(requested []uint64, got uint64) error {
215+ if !slices.Contains(requested, got) {
216+ return fmt.Errorf("gbm: modifier %#x not in the requested list %#x", got, requested)
217+ }
218+ return nil
219+}
220+
221+// checkOneObject fails when the plane fds are not all the same dmabuf:
222+// disjoint planes cannot be imported as one memory object.
223+func checkOneObject(fds []int) error {
224+ var first unix.Stat_t
225+ for i, fd := range fds {
226+ var st unix.Stat_t
227+ if err := unix.Fstat(fd, &st); err != nil {
228+ return fmt.Errorf("gbm: stat plane %d: %w", i, err)
229+ }
230+ if i == 0 {
231+ first = st
232+ continue
233+ }
234+ if st.Dev != first.Dev || st.Ino != first.Ino {
235+ return fmt.Errorf("gbm: plane %d is a different dmabuf than plane 0", i)
236+ }
237+ }
238+ return nil
239+}
240+
241+func (b *Buffer) closeFDs() {
242+ for _, p := range b.planes {
243+ _ = unix.Close(p.fd)
244+ }
245+ b.planes = nil
246+}
247+
248+// DMABuf returns the image as a dmabuf with fresh descriptors (the caller
249+// closes them); the buffer keeps its own.
250+func (b *Buffer) DMABuf() (ports.DMABuf, error) {
251+ if b == nil || b.bo == 0 {
252+ return ports.DMABuf{}, errors.New("gbm buffer closed")
253+ }
254+ out := ports.DMABuf{Width: b.width, Height: b.height, Format: b.format, Modifier: b.modifier}
255+ for _, p := range b.planes {
256+ fd, err := unix.FcntlInt(uintptr(p.fd), unix.F_DUPFD_CLOEXEC, 0)
257+ if err != nil {
258+ for _, q := range out.Planes {
259+ _ = q.File.Close()
260+ }
261+ return ports.DMABuf{}, fmt.Errorf("duplicate gbm plane fd: %w", err)
262+ }
263+ out.Planes = append(out.Planes, ports.DMABufPlane{File: os.NewFile(uintptr(fd), "neferwl-gbm"), Offset: p.offset, Stride: p.stride})
264+ }
265+ return out, nil
266+}
267+
268+// Close frees the buffer and its descriptors.
269+func (b *Buffer) Close() {
270+ if b == nil || b.bo == 0 {
271+ return
272+ }
273+ b.closeFDs()
274+ boDestroy(b.bo)
275+ b.bo = 0
276+}
A internal/adapters/drm/gbm/gbm_test.go +88 -0
@@ -0,0 +1,88 @@
1+package gbm
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// An allocator answer outside the request is refused, including an implicit
12+// layout (DRM_FORMAT_MOD_INVALID) that Vulkan cannot import; only a
13+// linear-only request takes it, as linear.
14+func TestCheckModifier(t *testing.T) {
15+ const tiled = 0x300000000606014
16+ for _, tc := range []struct {
17+ name string
18+ requested []uint64
19+ got uint64
20+ ok bool
21+ }{
22+ {"listed", []uint64{tiled, 0}, tiled, true},
23+ {"linear listed", []uint64{tiled, 0}, 0, true},
24+ {"not listed", []uint64{tiled}, 0, false},
25+ {"other vendor", []uint64{tiled}, 0x200000000401b03, false},
26+ {"empty list", nil, 0, false},
27+ {"linear only takes linear", []uint64{0}, 0, true},
28+ {"linear only takes invalid", []uint64{0}, modInvalid, true},
29+ {"linear only refuses others", []uint64{0}, tiled, false},
30+ {"tiled refuses invalid", []uint64{tiled}, modInvalid, false},
31+ {"tiled or linear refuses invalid", []uint64{tiled, 0}, modInvalid, false},
32+ {"0 and 5 refuses invalid", []uint64{0, 5}, modInvalid, false},
33+ } {
34+ if err := checkModifier(acceptedModifiers(tc.requested), tc.got); (err == nil) != tc.ok {
35+ t.Errorf("%s: %v", tc.name, err)
36+ }
37+ }
38+}
39+
40+// Planes must be one dmabuf: the renderer imports a single memory object.
41+// Duplicates of one descriptor are the same inode; two files are not.
42+func TestCheckOneObject(t *testing.T) {
43+ f, err := os.CreateTemp(t.TempDir(), "a")
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer f.Close()
48+ g, err := os.CreateTemp(t.TempDir(), "b")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ defer g.Close()
53+ dup, err := unix.FcntlInt(f.Fd(), unix.F_DUPFD_CLOEXEC, 0)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer unix.Close(dup)
58+ fd := int(f.Fd())
59+ if err := checkOneObject([]int{fd}); err != nil {
60+ t.Errorf("one plane: %v", err)
61+ }
62+ if err := checkOneObject([]int{fd, dup, fd}); err != nil {
63+ t.Errorf("shared planes: %v", err)
64+ }
65+ err = checkOneObject([]int{fd, int(g.Fd())})
66+ if err == nil || !strings.Contains(err.Error(), "plane 1") {
67+ t.Errorf("disjoint planes: %v", err)
68+ }
69+ if err := checkOneObject([]int{fd, -1}); err == nil {
70+ t.Error("bad descriptor accepted")
71+ }
72+}
73+
74+// A closed device and bad requests are refused without touching libgbm.
75+func TestCreateRejectsBadRequests(t *testing.T) {
76+ if _, err := (*Device)(nil).Create(8, 8, 0, []uint64{0}, UseRendering); err == nil {
77+ t.Error("nil device accepted")
78+ }
79+ d := &Device{dev: 1}
80+ for _, tc := range []struct {
81+ w, h int
82+ mods []uint64
83+ }{{0, 8, []uint64{0}}, {8, 0, []uint64{0}}, {8, 8, nil}} {
84+ if _, err := d.Create(tc.w, tc.h, 0, tc.mods, UseRendering); err == nil {
85+ t.Errorf("%dx%d with %d modifiers accepted", tc.w, tc.h, len(tc.mods))
86+ }
87+ }
88+}
@@ -0,0 +1,88 @@
1+package gbm
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// An allocator answer outside the request is refused, including an implicit
12+// layout (DRM_FORMAT_MOD_INVALID) that Vulkan cannot import; only a
13+// linear-only request takes it, as linear.
14+func TestCheckModifier(t *testing.T) {
15+ const tiled = 0x300000000606014
16+ for _, tc := range []struct {
17+ name string
18+ requested []uint64
19+ got uint64
20+ ok bool
21+ }{
22+ {"listed", []uint64{tiled, 0}, tiled, true},
23+ {"linear listed", []uint64{tiled, 0}, 0, true},
24+ {"not listed", []uint64{tiled}, 0, false},
25+ {"other vendor", []uint64{tiled}, 0x200000000401b03, false},
26+ {"empty list", nil, 0, false},
27+ {"linear only takes linear", []uint64{0}, 0, true},
28+ {"linear only takes invalid", []uint64{0}, modInvalid, true},
29+ {"linear only refuses others", []uint64{0}, tiled, false},
30+ {"tiled refuses invalid", []uint64{tiled}, modInvalid, false},
31+ {"tiled or linear refuses invalid", []uint64{tiled, 0}, modInvalid, false},
32+ {"0 and 5 refuses invalid", []uint64{0, 5}, modInvalid, false},
33+ } {
34+ if err := checkModifier(acceptedModifiers(tc.requested), tc.got); (err == nil) != tc.ok {
35+ t.Errorf("%s: %v", tc.name, err)
36+ }
37+ }
38+}
39+
40+// Planes must be one dmabuf: the renderer imports a single memory object.
41+// Duplicates of one descriptor are the same inode; two files are not.
42+func TestCheckOneObject(t *testing.T) {
43+ f, err := os.CreateTemp(t.TempDir(), "a")
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer f.Close()
48+ g, err := os.CreateTemp(t.TempDir(), "b")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ defer g.Close()
53+ dup, err := unix.FcntlInt(f.Fd(), unix.F_DUPFD_CLOEXEC, 0)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer unix.Close(dup)
58+ fd := int(f.Fd())
59+ if err := checkOneObject([]int{fd}); err != nil {
60+ t.Errorf("one plane: %v", err)
61+ }
62+ if err := checkOneObject([]int{fd, dup, fd}); err != nil {
63+ t.Errorf("shared planes: %v", err)
64+ }
65+ err = checkOneObject([]int{fd, int(g.Fd())})
66+ if err == nil || !strings.Contains(err.Error(), "plane 1") {
67+ t.Errorf("disjoint planes: %v", err)
68+ }
69+ if err := checkOneObject([]int{fd, -1}); err == nil {
70+ t.Error("bad descriptor accepted")
71+ }
72+}
73+
74+// A closed device and bad requests are refused without touching libgbm.
75+func TestCreateRejectsBadRequests(t *testing.T) {
76+ if _, err := (*Device)(nil).Create(8, 8, 0, []uint64{0}, UseRendering); err == nil {
77+ t.Error("nil device accepted")
78+ }
79+ d := &Device{dev: 1}
80+ for _, tc := range []struct {
81+ w, h int
82+ mods []uint64
83+ }{{0, 8, []uint64{0}}, {8, 0, []uint64{0}}, {8, 8, nil}} {
84+ if _, err := d.Create(tc.w, tc.h, 0, tc.mods, UseRendering); err == nil {
85+ t.Errorf("%dx%d with %d modifiers accepted", tc.w, tc.h, len(tc.mods))
86+ }
87+ }
88+}
M internal/adapters/drm/hdr_test.go +1 -0
@@ -148,6 +148,7 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
148148
149149 func TestHDRSuccessfulModeset(t *testing.T) {
150150 o, k, commits := testOutput(t)
151+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
151152 o.cursor = nil
152153 o.hdr.cap = hdrCapability{Capable: true}
153154 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
@@ -148,6 +148,7 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
148 148
149 func TestHDRSuccessfulModeset(t *testing.T) {149 func TestHDRSuccessfulModeset(t *testing.T) {
150 o, k, commits := testOutput(t)150 o, k, commits := testOutput(t)
151+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
151 o.cursor = nil152 o.cursor = nil
152 o.hdr.cap = hdrCapability{Capable: true}153 o.hdr.cap = hdrCapability{Capable: true}
153 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}154 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
A internal/adapters/drm/image_allocator_mock_test.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockimageAllocator creates a new instance of mockimageAllocator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockimageAllocator(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockimageAllocator {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockimageAllocator{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockimageAllocator is an autogenerated mock type for the imageAllocator type
35+type mockimageAllocator struct {
36+ mock.Mock
37+}
38+
39+type mockimageAllocator_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockimageAllocator) EXPECT() *mockimageAllocator_Expecter {
44+ return &mockimageAllocator_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockimageAllocator
48+func (_mock *mockimageAllocator) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockimageAllocator_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockimageAllocator_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockimageAllocator_Expecter) close() *mockimageAllocator_close_Call {
60+ return &mockimageAllocator_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockimageAllocator_close_Call) Run(run func()) *mockimageAllocator_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockimageAllocator_close_Call) Return() *mockimageAllocator_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockimageAllocator_close_Call) RunAndReturn(run func()) *mockimageAllocator_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// create provides a mock function for the type mockimageAllocator
81+func (_mock *mockimageAllocator) create(width int, height int, format uint32, modifiers []uint64) (imageBuffer, error) {
82+ ret := _mock.Called(width, height, format, modifiers)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for create")
86+ }
87+
88+ var r0 imageBuffer
89+ var r1 error
90+ if returnFunc, ok := ret.Get(0).(func(int, int, uint32, []uint64) (imageBuffer, error)); ok {
91+ return returnFunc(width, height, format, modifiers)
92+ }
93+ if returnFunc, ok := ret.Get(0).(func(int, int, uint32, []uint64) imageBuffer); ok {
94+ r0 = returnFunc(width, height, format, modifiers)
95+ } else {
96+ if ret.Get(0) != nil {
97+ r0 = ret.Get(0).(imageBuffer)
98+ }
99+ }
100+ if returnFunc, ok := ret.Get(1).(func(int, int, uint32, []uint64) error); ok {
101+ r1 = returnFunc(width, height, format, modifiers)
102+ } else {
103+ r1 = ret.Error(1)
104+ }
105+ return r0, r1
106+}
107+
108+// mockimageAllocator_create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'create'
109+type mockimageAllocator_create_Call struct {
110+ *mock.Call
111+}
112+
113+// create is a helper method to define mock.On call
114+// - width int
115+// - height int
116+// - format uint32
117+// - modifiers []uint64
118+func (_e *mockimageAllocator_Expecter) create(width any, height any, format any, modifiers any) *mockimageAllocator_create_Call {
119+ return &mockimageAllocator_create_Call{Call: _e.mock.On("create", width, height, format, modifiers)}
120+}
121+
122+func (_c *mockimageAllocator_create_Call) Run(run func(width int, height int, format uint32, modifiers []uint64)) *mockimageAllocator_create_Call {
123+ _c.Call.Run(func(args mock.Arguments) {
124+ var arg0 int
125+ if args[0] != nil {
126+ arg0 = args[0].(int)
127+ }
128+ var arg1 int
129+ if args[1] != nil {
130+ arg1 = args[1].(int)
131+ }
132+ var arg2 uint32
133+ if args[2] != nil {
134+ arg2 = args[2].(uint32)
135+ }
136+ var arg3 []uint64
137+ if args[3] != nil {
138+ arg3 = args[3].([]uint64)
139+ }
140+ run(
141+ arg0,
142+ arg1,
143+ arg2,
144+ arg3,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *mockimageAllocator_create_Call) Return(imageBufferMoqParam imageBuffer, err error) *mockimageAllocator_create_Call {
151+ _c.Call.Return(imageBufferMoqParam, err)
152+ return _c
153+}
154+
155+func (_c *mockimageAllocator_create_Call) RunAndReturn(run func(width int, height int, format uint32, modifiers []uint64) (imageBuffer, error)) *mockimageAllocator_create_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockimageAllocator creates a new instance of mockimageAllocator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockimageAllocator(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockimageAllocator {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockimageAllocator{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockimageAllocator is an autogenerated mock type for the imageAllocator type
35+type mockimageAllocator struct {
36+ mock.Mock
37+}
38+
39+type mockimageAllocator_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockimageAllocator) EXPECT() *mockimageAllocator_Expecter {
44+ return &mockimageAllocator_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockimageAllocator
48+func (_mock *mockimageAllocator) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockimageAllocator_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockimageAllocator_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockimageAllocator_Expecter) close() *mockimageAllocator_close_Call {
60+ return &mockimageAllocator_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockimageAllocator_close_Call) Run(run func()) *mockimageAllocator_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockimageAllocator_close_Call) Return() *mockimageAllocator_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockimageAllocator_close_Call) RunAndReturn(run func()) *mockimageAllocator_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// create provides a mock function for the type mockimageAllocator
81+func (_mock *mockimageAllocator) create(width int, height int, format uint32, modifiers []uint64) (imageBuffer, error) {
82+ ret := _mock.Called(width, height, format, modifiers)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for create")
86+ }
87+
88+ var r0 imageBuffer
89+ var r1 error
90+ if returnFunc, ok := ret.Get(0).(func(int, int, uint32, []uint64) (imageBuffer, error)); ok {
91+ return returnFunc(width, height, format, modifiers)
92+ }
93+ if returnFunc, ok := ret.Get(0).(func(int, int, uint32, []uint64) imageBuffer); ok {
94+ r0 = returnFunc(width, height, format, modifiers)
95+ } else {
96+ if ret.Get(0) != nil {
97+ r0 = ret.Get(0).(imageBuffer)
98+ }
99+ }
100+ if returnFunc, ok := ret.Get(1).(func(int, int, uint32, []uint64) error); ok {
101+ r1 = returnFunc(width, height, format, modifiers)
102+ } else {
103+ r1 = ret.Error(1)
104+ }
105+ return r0, r1
106+}
107+
108+// mockimageAllocator_create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'create'
109+type mockimageAllocator_create_Call struct {
110+ *mock.Call
111+}
112+
113+// create is a helper method to define mock.On call
114+// - width int
115+// - height int
116+// - format uint32
117+// - modifiers []uint64
118+func (_e *mockimageAllocator_Expecter) create(width any, height any, format any, modifiers any) *mockimageAllocator_create_Call {
119+ return &mockimageAllocator_create_Call{Call: _e.mock.On("create", width, height, format, modifiers)}
120+}
121+
122+func (_c *mockimageAllocator_create_Call) Run(run func(width int, height int, format uint32, modifiers []uint64)) *mockimageAllocator_create_Call {
123+ _c.Call.Run(func(args mock.Arguments) {
124+ var arg0 int
125+ if args[0] != nil {
126+ arg0 = args[0].(int)
127+ }
128+ var arg1 int
129+ if args[1] != nil {
130+ arg1 = args[1].(int)
131+ }
132+ var arg2 uint32
133+ if args[2] != nil {
134+ arg2 = args[2].(uint32)
135+ }
136+ var arg3 []uint64
137+ if args[3] != nil {
138+ arg3 = args[3].([]uint64)
139+ }
140+ run(
141+ arg0,
142+ arg1,
143+ arg2,
144+ arg3,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *mockimageAllocator_create_Call) Return(imageBufferMoqParam imageBuffer, err error) *mockimageAllocator_create_Call {
151+ _c.Call.Return(imageBufferMoqParam, err)
152+ return _c
153+}
154+
155+func (_c *mockimageAllocator_create_Call) RunAndReturn(run func(width int, height int, format uint32, modifiers []uint64) (imageBuffer, error)) *mockimageAllocator_create_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
A internal/adapters/drm/image_buffer_mock_test.go +132 -0
@@ -0,0 +1,132 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockimageBuffer creates a new instance of mockimageBuffer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockimageBuffer(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockimageBuffer {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockimageBuffer{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockimageBuffer is an autogenerated mock type for the imageBuffer type
36+type mockimageBuffer struct {
37+ mock.Mock
38+}
39+
40+type mockimageBuffer_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockimageBuffer) EXPECT() *mockimageBuffer_Expecter {
45+ return &mockimageBuffer_Expecter{mock: &_m.Mock}
46+}
47+
48+// close provides a mock function for the type mockimageBuffer
49+func (_mock *mockimageBuffer) close() {
50+ _mock.Called()
51+ return
52+}
53+
54+// mockimageBuffer_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
55+type mockimageBuffer_close_Call struct {
56+ *mock.Call
57+}
58+
59+// close is a helper method to define mock.On call
60+func (_e *mockimageBuffer_Expecter) close() *mockimageBuffer_close_Call {
61+ return &mockimageBuffer_close_Call{Call: _e.mock.On("close")}
62+}
63+
64+func (_c *mockimageBuffer_close_Call) Run(run func()) *mockimageBuffer_close_Call {
65+ _c.Call.Run(func(args mock.Arguments) {
66+ run()
67+ })
68+ return _c
69+}
70+
71+func (_c *mockimageBuffer_close_Call) Return() *mockimageBuffer_close_Call {
72+ _c.Call.Return()
73+ return _c
74+}
75+
76+func (_c *mockimageBuffer_close_Call) RunAndReturn(run func()) *mockimageBuffer_close_Call {
77+ _c.Run(run)
78+ return _c
79+}
80+
81+// dmabuf provides a mock function for the type mockimageBuffer
82+func (_mock *mockimageBuffer) dmabuf() (ports.DMABuf, error) {
83+ ret := _mock.Called()
84+
85+ if len(ret) == 0 {
86+ panic("no return value specified for dmabuf")
87+ }
88+
89+ var r0 ports.DMABuf
90+ var r1 error
91+ if returnFunc, ok := ret.Get(0).(func() (ports.DMABuf, error)); ok {
92+ return returnFunc()
93+ }
94+ if returnFunc, ok := ret.Get(0).(func() ports.DMABuf); ok {
95+ r0 = returnFunc()
96+ } else {
97+ r0 = ret.Get(0).(ports.DMABuf)
98+ }
99+ if returnFunc, ok := ret.Get(1).(func() error); ok {
100+ r1 = returnFunc()
101+ } else {
102+ r1 = ret.Error(1)
103+ }
104+ return r0, r1
105+}
106+
107+// mockimageBuffer_dmabuf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'dmabuf'
108+type mockimageBuffer_dmabuf_Call struct {
109+ *mock.Call
110+}
111+
112+// dmabuf is a helper method to define mock.On call
113+func (_e *mockimageBuffer_Expecter) dmabuf() *mockimageBuffer_dmabuf_Call {
114+ return &mockimageBuffer_dmabuf_Call{Call: _e.mock.On("dmabuf")}
115+}
116+
117+func (_c *mockimageBuffer_dmabuf_Call) Run(run func()) *mockimageBuffer_dmabuf_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ run()
120+ })
121+ return _c
122+}
123+
124+func (_c *mockimageBuffer_dmabuf_Call) Return(dMABuf ports.DMABuf, err error) *mockimageBuffer_dmabuf_Call {
125+ _c.Call.Return(dMABuf, err)
126+ return _c
127+}
128+
129+func (_c *mockimageBuffer_dmabuf_Call) RunAndReturn(run func() (ports.DMABuf, error)) *mockimageBuffer_dmabuf_Call {
130+ _c.Call.Return(run)
131+ return _c
132+}
@@ -0,0 +1,132 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockimageBuffer creates a new instance of mockimageBuffer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockimageBuffer(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockimageBuffer {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockimageBuffer{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockimageBuffer is an autogenerated mock type for the imageBuffer type
36+type mockimageBuffer struct {
37+ mock.Mock
38+}
39+
40+type mockimageBuffer_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockimageBuffer) EXPECT() *mockimageBuffer_Expecter {
45+ return &mockimageBuffer_Expecter{mock: &_m.Mock}
46+}
47+
48+// close provides a mock function for the type mockimageBuffer
49+func (_mock *mockimageBuffer) close() {
50+ _mock.Called()
51+ return
52+}
53+
54+// mockimageBuffer_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
55+type mockimageBuffer_close_Call struct {
56+ *mock.Call
57+}
58+
59+// close is a helper method to define mock.On call
60+func (_e *mockimageBuffer_Expecter) close() *mockimageBuffer_close_Call {
61+ return &mockimageBuffer_close_Call{Call: _e.mock.On("close")}
62+}
63+
64+func (_c *mockimageBuffer_close_Call) Run(run func()) *mockimageBuffer_close_Call {
65+ _c.Call.Run(func(args mock.Arguments) {
66+ run()
67+ })
68+ return _c
69+}
70+
71+func (_c *mockimageBuffer_close_Call) Return() *mockimageBuffer_close_Call {
72+ _c.Call.Return()
73+ return _c
74+}
75+
76+func (_c *mockimageBuffer_close_Call) RunAndReturn(run func()) *mockimageBuffer_close_Call {
77+ _c.Run(run)
78+ return _c
79+}
80+
81+// dmabuf provides a mock function for the type mockimageBuffer
82+func (_mock *mockimageBuffer) dmabuf() (ports.DMABuf, error) {
83+ ret := _mock.Called()
84+
85+ if len(ret) == 0 {
86+ panic("no return value specified for dmabuf")
87+ }
88+
89+ var r0 ports.DMABuf
90+ var r1 error
91+ if returnFunc, ok := ret.Get(0).(func() (ports.DMABuf, error)); ok {
92+ return returnFunc()
93+ }
94+ if returnFunc, ok := ret.Get(0).(func() ports.DMABuf); ok {
95+ r0 = returnFunc()
96+ } else {
97+ r0 = ret.Get(0).(ports.DMABuf)
98+ }
99+ if returnFunc, ok := ret.Get(1).(func() error); ok {
100+ r1 = returnFunc()
101+ } else {
102+ r1 = ret.Error(1)
103+ }
104+ return r0, r1
105+}
106+
107+// mockimageBuffer_dmabuf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'dmabuf'
108+type mockimageBuffer_dmabuf_Call struct {
109+ *mock.Call
110+}
111+
112+// dmabuf is a helper method to define mock.On call
113+func (_e *mockimageBuffer_Expecter) dmabuf() *mockimageBuffer_dmabuf_Call {
114+ return &mockimageBuffer_dmabuf_Call{Call: _e.mock.On("dmabuf")}
115+}
116+
117+func (_c *mockimageBuffer_dmabuf_Call) Run(run func()) *mockimageBuffer_dmabuf_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ run()
120+ })
121+ return _c
122+}
123+
124+func (_c *mockimageBuffer_dmabuf_Call) Return(dMABuf ports.DMABuf, err error) *mockimageBuffer_dmabuf_Call {
125+ _c.Call.Return(dMABuf, err)
126+ return _c
127+}
128+
129+func (_c *mockimageBuffer_dmabuf_Call) RunAndReturn(run func() (ports.DMABuf, error)) *mockimageBuffer_dmabuf_Call {
130+ _c.Call.Return(run)
131+ return _c
132+}
M internal/adapters/drm/images.go +168 -37
@@ -13,13 +13,21 @@ import (
1313
1414 // Output images: the renderer targets frames alternate between.
1515
16-// imageKind is how output images are made, best first.
16+// imageKind is how output images are made, best first. Each kind is tried
17+// with GBM allocation first, then with images the renderer allocates and
18+// exports.
1719 type imageKind int
1820
1921 const (
20- imagesDriver imageKind = iota // exported, modifier chosen by the driver (DCC planes allowed)
21- imagesSinglePlane // exported, driver modifier with one memory plane
22- imagesLinear // exported, linear
22+ imagesDriver imageKind = iota // modifier chosen by the driver (DCC planes allowed)
23+ imagesSinglePlane // driver modifier with one memory plane
24+ imagesLinear // linear
25+)
26+
27+// Who allocated the output images (the "alloc" log field).
28+const (
29+ allocGBM = "gbm"
30+ allocVulkan = "vulkan"
2331 )
2432
2533 // maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2).
@@ -59,7 +67,7 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
5967 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
6068 o.hdr.failed = true
6169 o.hdr.on = false
62- o.freeImages()
70+ o.dropImages(r)
6371 r.SetHDR(0)
6472 _, _ = r.ExportTargets(0, nil, false)
6573 // A failed test commit can leave the old HDR mode on screen;
@@ -76,8 +84,11 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
7684 }
7785
7886 func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
87+ // skipGBM makes the next setup use renderer-allocated images for kind:
88+ // GBM images of this kind were refused by the modeset test.
89+ skipGBM := false
7990 for {
80- got, err := o.setupImages(r, kind, cause)
91+ got, err := o.setupImages(r, kind, cause, skipGBM)
8192 if err != nil {
8293 return err
8394 }
@@ -97,19 +108,31 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
97108 // are fine; retry the protected modeset later.
98109 return protectedCommitError{err}
99110 }
100- if !refused(err) || got == imagesLinear {
101- o.freeImages()
111+ if !refused(err) {
112+ o.dropImages(r)
113+ return err
114+ }
115+ if len(o.images) > 0 {
116+ // GBM images (the renderer imported them).
117+ // The renderer's own images of this kind may still pass.
118+ o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Str("alloc", allocGBM).Msg("modeset refused the output images")
119+ o.dropImages(r)
120+ kind, cause, skipGBM = got, err, true
121+ continue
122+ }
123+ if got == imagesLinear {
124+ o.dropImages(r)
102125 return err
103126 }
104127 if o.imageMod == 0 {
105128 // The refused images were already linear: the later kinds
106129 // would export the same ones again.
107- o.freeImages()
130+ o.dropImages(r)
108131 return err
109132 }
110- o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
111- o.freeImages()
112- kind, cause = got+1, err
133+ o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Str("alloc", allocVulkan).Msg("modeset refused the output images")
134+ o.dropImages(r)
135+ kind, cause, skipGBM = got+1, err, false
113136 if kind == imagesSinglePlane && o.planes == 1 {
114137 // The refused images already had one plane: single-plane
115138 // images would be the same ones.
@@ -119,11 +142,15 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
119142 }
120143
121144 // setupImages gives the output the two renderer images it flips between,
122-// exported as dmabufs. There is no CPU fallback (ADR 014): a GPU that
123-// cannot export a scanout-capable image cannot drive the output.
124-func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
145+// as dmabufs: allocated with GBM and imported by the renderer, else
146+// allocated and exported by the renderer, for each kind in turn. skipGBM
147+// leaves GBM out for the first kind. There is no CPU fallback (ADR 014): a
148+// GPU that cannot export a scanout-capable image cannot drive the output.
149+func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error, skipGBM bool) (imageKind, error) {
125150 err := cause
151+ start := kind
126152 for ; kind <= imagesLinear; kind++ {
153+ noGBM := skipGBM && kind == start
127154 if o.runContext != nil && o.runContext.Err() != nil {
128155 return kind, o.runContext.Err()
129156 }
@@ -159,10 +186,20 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
159186 // attempt was the single-plane one.
160187 continue
161188 }
189+ if !noGBM && o.openAlloc != nil {
190+ gerr := o.gbmImages(r, mods, kind == imagesSinglePlane)
191+ if gerr == nil {
192+ return kind, nil
193+ }
194+ o.log.Info().Err(gerr).Str("connector", o.conn.name).Int("kind", int(kind)).Str("alloc", allocGBM).Msg("output image allocation")
195+ }
196+ // Deprecated fallback (neferwl#23): renderer-exported images, kept
197+ // while GBM is validated on few setups. Remove once no report shows
198+ // "alloc":"vulkan" for a few releases.
162199 if err = o.exportImages(r, mods, kind == imagesSinglePlane); err == nil {
163200 return kind, nil
164201 }
165- o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")
202+ o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Str("alloc", allocVulkan).Msg("output image export")
166203 }
167204 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
168205 }
@@ -187,32 +224,69 @@ func (o *Output) primaryModifiers(format uint32) []uint64 {
187224 return mods
188225 }
189226
227+// closeBufs closes every plane file of bufs. The callers own them: the
228+// renderer and the framebuffers keep their own references to the memory.
229+func closeBufs(bufs []ports.DMABuf) {
230+ for _, b := range bufs {
231+ for _, p := range b.Planes {
232+ _ = p.File.Close()
233+ }
234+ }
235+}
236+
190237 // exportImages makes the renderer's exported targets the output images.
238+//
239+// Deprecated fallback for gbmImages, to remove with neferwl#23. Exported
240+// Vulkan images corrupt scanout on nvidia-drm.
191241 func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error {
242+ o.dropImages(r)
192243 bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane)
193244 if err != nil {
194245 return err
195246 }
247+ defer closeBufs(bufs)
248+ if err = o.showBufs(r, bufs, allocVulkan); err != nil {
249+ o.dropImages(r)
250+ _, _ = r.ExportTargets(0, nil, false)
251+ return err
252+ }
253+ return nil
254+}
255+
256+// showBufs makes framebuffers of the output images bufs (already the
257+// renderer's targets), clears them and records their layout. The caller
258+// closes the plane files and drops the images on error.
259+func (o *Output) showBufs(r ports.Renderer, bufs []ports.DMABuf, alloc string) error {
196260 o.planes, o.imageMod = 0, 0
197261 for i := range bufs {
198- if len(bufs[i].Planes) == 0 || len(bufs[i].Planes) > maxFBPlanes {
199- err = errors.Join(err, fmt.Errorf("output image has %d planes", len(bufs[i].Planes)))
200- }
201- if err == nil {
202- o.fbs[i], err = o.k.addFB(&bufs[i], o.imageFormat())
262+ if n := len(bufs[i].Planes); n == 0 || n > maxFBPlanes {
263+ return fmt.Errorf("output image has %d planes", n)
203264 }
204- // Every plane owns its file; the framebuffer keeps its own
205- // reference to the buffer.
206- for _, p := range bufs[i].Planes {
207- p.File.Close()
265+ fb, err := o.k.addFB(&bufs[i], o.imageFormat())
266+ if err != nil {
267+ return err
208268 }
269+ o.fbs[i] = fb
270+ }
271+ if err := o.clearImages(r); err != nil {
272+ return err
209273 }
210- // GPU memory starts undefined (old VRAM contents): clear both images
211- // before the modeset shows one.
274+ o.planes, o.imageMod = len(bufs[0].Planes), bufs[0].Modifier
275+ ev := o.log.Info()
276+ if alloc == allocVulkan {
277+ // The deprecated fallback is in use: make it visible in reports.
278+ ev = o.log.Warn()
279+ }
280+ ev.Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Int("planes", o.planes).Str("alloc", alloc).Msg("zero-copy output")
281+ return nil
282+}
283+
284+// clearImages draws black into both output images and waits for the GPU.
285+// GPU memory starts undefined (old VRAM contents): both are cleared before
286+// the modeset shows one.
287+func (o *Output) clearImages(r ports.Renderer) error {
288+ var err error
212289 for i := range o.fbs {
213- if err != nil {
214- break
215- }
216290 r.UseTarget(i)
217291 var done *os.File
218292 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
@@ -224,18 +298,71 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool)
224298 err = errors.Join(err, syncfile.Wait(waitCtx, done))
225299 done.Close()
226300 }
301+ if err != nil {
302+ return err
303+ }
227304 }
228- if err != nil {
229- o.freeImages()
230- _, _ = r.ExportTargets(0, nil, false)
305+ return nil
306+}
307+
308+// gbmImages allocates the output images with GBM, makes them the
309+// renderer's targets and framebuffers, and clears them. The modifiers are
310+// those the plane lists (mods, as for exportImages) that the renderer can
311+// render into and import. On failure nothing is left allocated.
312+func (o *Output) gbmImages(r ports.Renderer, mods []uint64, singlePlane bool) (err error) {
313+ o.dropImages(r)
314+ if o.alloc == nil {
315+ if o.allocErr != nil {
316+ return o.allocErr
317+ }
318+ if o.alloc, o.allocErr = o.openAlloc(); o.allocErr != nil {
319+ o.log.Info().Err(o.allocErr).Str("connector", o.conn.name).Str("alloc", allocGBM).Msg("GBM unavailable; the renderer allocates the output images")
320+ return o.allocErr
321+ }
322+ }
323+ usable := r.TargetModifiers(mods, singlePlane)
324+ if len(usable) == 0 {
325+ return errors.New("no modifier both the plane and the renderer accept for GBM images")
326+ }
327+ var bufs []ports.DMABuf
328+ defer func() {
329+ closeBufs(bufs)
330+ if err != nil {
331+ o.dropImages(r)
332+ }
333+ }()
334+ for range o.fbs {
335+ img, cerr := o.alloc.create(o.Width(), o.Height(), o.imageFormat(), usable)
336+ if cerr != nil {
337+ return cerr
338+ }
339+ o.images = append(o.images, img)
340+ b, derr := img.dmabuf()
341+ if derr != nil {
342+ return derr
343+ }
344+ bufs = append(bufs, b)
345+ }
346+ if err = r.ImportTargets(bufs); err != nil {
231347 return err
232348 }
233- o.planes, o.imageMod = len(bufs[0].Planes), bufs[0].Modifier
234- o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Int("planes", len(bufs[0].Planes)).Msg("zero-copy output")
235- return nil
349+ return o.showBufs(r, bufs, allocGBM)
350+}
351+
352+// dropImages frees the output images: their framebuffers, the GBM buffers
353+// and the renderer targets built over them (they would keep the memory
354+// alive after the buffers are gone). It is a no-op when there are none.
355+func (o *Output) dropImages(r ports.Renderer) {
356+ if len(o.images) > 0 {
357+ _ = r.ImportTargets(nil)
358+ }
359+ o.freeImages()
236360 }
237361
238-// freeImages removes the output images' framebuffers.
362+// freeImages removes the output images' framebuffers and frees the images
363+// GBM allocated. Callers that still have a renderer use dropImages: this
364+// alone leaves the renderer's targets over the freed images (the renderer
365+// drops them when it closes, before Close frees the output's images).
239366 func (o *Output) freeImages() {
240367 o.front.forget()
241368 o.forgetOverlayProbes()
@@ -245,4 +372,8 @@ func (o *Output) freeImages() {
245372 }
246373 o.fbs[i] = 0
247374 }
375+ for _, img := range o.images {
376+ img.close()
377+ }
378+ o.images = nil
248379 }
@@ -13,13 +13,21 @@ import (
13 13
14 // Output images: the renderer targets frames alternate between.14 // Output images: the renderer targets frames alternate between.
15 15
16-// imageKind is how output images are made, best first.16+// imageKind is how output images are made, best first. Each kind is tried
17+// with GBM allocation first, then with images the renderer allocates and
18+// exports.
17 type imageKind int19 type imageKind int
18 20
19 const (21 const (
20- imagesDriver imageKind = iota // exported, modifier chosen by the driver (DCC planes allowed)22+ imagesDriver imageKind = iota // modifier chosen by the driver (DCC planes allowed)
21- imagesSinglePlane // exported, driver modifier with one memory plane23+ imagesSinglePlane // driver modifier with one memory plane
22- imagesLinear // exported, linear24+ imagesLinear // linear
25+)
26+
27+// Who allocated the output images (the "alloc" log field).
28+const (
29+ allocGBM = "gbm"
30+ allocVulkan = "vulkan"
23 )31 )
24 32
25 // maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2).33 // maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2).
@@ -59,7 +67,7 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
59 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")67 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
60 o.hdr.failed = true68 o.hdr.failed = true
61 o.hdr.on = false69 o.hdr.on = false
62- o.freeImages()70+ o.dropImages(r)
63 r.SetHDR(0)71 r.SetHDR(0)
64 _, _ = r.ExportTargets(0, nil, false)72 _, _ = r.ExportTargets(0, nil, false)
65 // A failed test commit can leave the old HDR mode on screen;73 // A failed test commit can leave the old HDR mode on screen;
@@ -76,8 +84,11 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
76 }84 }
77 85
78 func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {86 func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
87+ // skipGBM makes the next setup use renderer-allocated images for kind:
88+ // GBM images of this kind were refused by the modeset test.
89+ skipGBM := false
79 for {90 for {
80- got, err := o.setupImages(r, kind, cause)91+ got, err := o.setupImages(r, kind, cause, skipGBM)
81 if err != nil {92 if err != nil {
82 return err93 return err
83 }94 }
@@ -97,19 +108,31 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
97 // are fine; retry the protected modeset later.108 // are fine; retry the protected modeset later.
98 return protectedCommitError{err}109 return protectedCommitError{err}
99 }110 }
100- if !refused(err) || got == imagesLinear {111+ if !refused(err) {
101- o.freeImages()112+ o.dropImages(r)
113+ return err
114+ }
115+ if len(o.images) > 0 {
116+ // GBM images (the renderer imported them).
117+ // The renderer's own images of this kind may still pass.
118+ o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Str("alloc", allocGBM).Msg("modeset refused the output images")
119+ o.dropImages(r)
120+ kind, cause, skipGBM = got, err, true
121+ continue
122+ }
123+ if got == imagesLinear {
124+ o.dropImages(r)
102 return err125 return err
103 }126 }
104 if o.imageMod == 0 {127 if o.imageMod == 0 {
105 // The refused images were already linear: the later kinds128 // The refused images were already linear: the later kinds
106 // would export the same ones again.129 // would export the same ones again.
107- o.freeImages()130+ o.dropImages(r)
108 return err131 return err
109 }132 }
110- o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")133+ o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Str("alloc", allocVulkan).Msg("modeset refused the output images")
111- o.freeImages()134+ o.dropImages(r)
112- kind, cause = got+1, err135+ kind, cause, skipGBM = got+1, err, false
113 if kind == imagesSinglePlane && o.planes == 1 {136 if kind == imagesSinglePlane && o.planes == 1 {
114 // The refused images already had one plane: single-plane137 // The refused images already had one plane: single-plane
115 // images would be the same ones.138 // images would be the same ones.
@@ -119,11 +142,15 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
119 }142 }
120 143
121 // setupImages gives the output the two renderer images it flips between,144 // setupImages gives the output the two renderer images it flips between,
122-// exported as dmabufs. There is no CPU fallback (ADR 014): a GPU that145+// as dmabufs: allocated with GBM and imported by the renderer, else
123-// cannot export a scanout-capable image cannot drive the output.146+// allocated and exported by the renderer, for each kind in turn. skipGBM
124-func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {147+// leaves GBM out for the first kind. There is no CPU fallback (ADR 014): a
148+// GPU that cannot export a scanout-capable image cannot drive the output.
149+func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error, skipGBM bool) (imageKind, error) {
125 err := cause150 err := cause
151+ start := kind
126 for ; kind <= imagesLinear; kind++ {152 for ; kind <= imagesLinear; kind++ {
153+ noGBM := skipGBM && kind == start
127 if o.runContext != nil && o.runContext.Err() != nil {154 if o.runContext != nil && o.runContext.Err() != nil {
128 return kind, o.runContext.Err()155 return kind, o.runContext.Err()
129 }156 }
@@ -159,10 +186,20 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
159 // attempt was the single-plane one.186 // attempt was the single-plane one.
160 continue187 continue
161 }188 }
189+ if !noGBM && o.openAlloc != nil {
190+ gerr := o.gbmImages(r, mods, kind == imagesSinglePlane)
191+ if gerr == nil {
192+ return kind, nil
193+ }
194+ o.log.Info().Err(gerr).Str("connector", o.conn.name).Int("kind", int(kind)).Str("alloc", allocGBM).Msg("output image allocation")
195+ }
196+ // Deprecated fallback (neferwl#23): renderer-exported images, kept
197+ // while GBM is validated on few setups. Remove once no report shows
198+ // "alloc":"vulkan" for a few releases.
162 if err = o.exportImages(r, mods, kind == imagesSinglePlane); err == nil {199 if err = o.exportImages(r, mods, kind == imagesSinglePlane); err == nil {
163 return kind, nil200 return kind, nil
164 }201 }
165- o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")202+ o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Str("alloc", allocVulkan).Msg("output image export")
166 }203 }
167 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)204 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
168 }205 }
@@ -187,32 +224,69 @@ func (o *Output) primaryModifiers(format uint32) []uint64 {
187 return mods224 return mods
188 }225 }
189 226
227+// closeBufs closes every plane file of bufs. The callers own them: the
228+// renderer and the framebuffers keep their own references to the memory.
229+func closeBufs(bufs []ports.DMABuf) {
230+ for _, b := range bufs {
231+ for _, p := range b.Planes {
232+ _ = p.File.Close()
233+ }
234+ }
235+}
236+
190 // exportImages makes the renderer's exported targets the output images.237 // exportImages makes the renderer's exported targets the output images.
238+//
239+// Deprecated fallback for gbmImages, to remove with neferwl#23. Exported
240+// Vulkan images corrupt scanout on nvidia-drm.
191 func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error {241 func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error {
242+ o.dropImages(r)
192 bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane)243 bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane)
193 if err != nil {244 if err != nil {
194 return err245 return err
195 }246 }
247+ defer closeBufs(bufs)
248+ if err = o.showBufs(r, bufs, allocVulkan); err != nil {
249+ o.dropImages(r)
250+ _, _ = r.ExportTargets(0, nil, false)
251+ return err
252+ }
253+ return nil
254+}
255+
256+// showBufs makes framebuffers of the output images bufs (already the
257+// renderer's targets), clears them and records their layout. The caller
258+// closes the plane files and drops the images on error.
259+func (o *Output) showBufs(r ports.Renderer, bufs []ports.DMABuf, alloc string) error {
196 o.planes, o.imageMod = 0, 0260 o.planes, o.imageMod = 0, 0
197 for i := range bufs {261 for i := range bufs {
198- if len(bufs[i].Planes) == 0 || len(bufs[i].Planes) > maxFBPlanes {262+ if n := len(bufs[i].Planes); n == 0 || n > maxFBPlanes {
199- err = errors.Join(err, fmt.Errorf("output image has %d planes", len(bufs[i].Planes)))263+ return fmt.Errorf("output image has %d planes", n)
200- }
201- if err == nil {
202- o.fbs[i], err = o.k.addFB(&bufs[i], o.imageFormat())
203 }264 }
204- // Every plane owns its file; the framebuffer keeps its own265+ fb, err := o.k.addFB(&bufs[i], o.imageFormat())
205- // reference to the buffer.266+ if err != nil {
206- for _, p := range bufs[i].Planes {267+ return err
207- p.File.Close()
208 }268 }
269+ o.fbs[i] = fb
270+ }
271+ if err := o.clearImages(r); err != nil {
272+ return err
209 }273 }
210- // GPU memory starts undefined (old VRAM contents): clear both images274+ o.planes, o.imageMod = len(bufs[0].Planes), bufs[0].Modifier
211- // before the modeset shows one.275+ ev := o.log.Info()
276+ if alloc == allocVulkan {
277+ // The deprecated fallback is in use: make it visible in reports.
278+ ev = o.log.Warn()
279+ }
280+ ev.Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Int("planes", o.planes).Str("alloc", alloc).Msg("zero-copy output")
281+ return nil
282+}
283+
284+// clearImages draws black into both output images and waits for the GPU.
285+// GPU memory starts undefined (old VRAM contents): both are cleared before
286+// the modeset shows one.
287+func (o *Output) clearImages(r ports.Renderer) error {
288+ var err error
212 for i := range o.fbs {289 for i := range o.fbs {
213- if err != nil {
214- break
215- }
216 r.UseTarget(i)290 r.UseTarget(i)
217 var done *os.File291 var done *os.File
218 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {292 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
@@ -224,18 +298,71 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool)
224 err = errors.Join(err, syncfile.Wait(waitCtx, done))298 err = errors.Join(err, syncfile.Wait(waitCtx, done))
225 done.Close()299 done.Close()
226 }300 }
301+ if err != nil {
302+ return err
303+ }
227 }304 }
228- if err != nil {305+ return nil
229- o.freeImages()306+}
230- _, _ = r.ExportTargets(0, nil, false)307+
308+// gbmImages allocates the output images with GBM, makes them the
309+// renderer's targets and framebuffers, and clears them. The modifiers are
310+// those the plane lists (mods, as for exportImages) that the renderer can
311+// render into and import. On failure nothing is left allocated.
312+func (o *Output) gbmImages(r ports.Renderer, mods []uint64, singlePlane bool) (err error) {
313+ o.dropImages(r)
314+ if o.alloc == nil {
315+ if o.allocErr != nil {
316+ return o.allocErr
317+ }
318+ if o.alloc, o.allocErr = o.openAlloc(); o.allocErr != nil {
319+ o.log.Info().Err(o.allocErr).Str("connector", o.conn.name).Str("alloc", allocGBM).Msg("GBM unavailable; the renderer allocates the output images")
320+ return o.allocErr
321+ }
322+ }
323+ usable := r.TargetModifiers(mods, singlePlane)
324+ if len(usable) == 0 {
325+ return errors.New("no modifier both the plane and the renderer accept for GBM images")
326+ }
327+ var bufs []ports.DMABuf
328+ defer func() {
329+ closeBufs(bufs)
330+ if err != nil {
331+ o.dropImages(r)
332+ }
333+ }()
334+ for range o.fbs {
335+ img, cerr := o.alloc.create(o.Width(), o.Height(), o.imageFormat(), usable)
336+ if cerr != nil {
337+ return cerr
338+ }
339+ o.images = append(o.images, img)
340+ b, derr := img.dmabuf()
341+ if derr != nil {
342+ return derr
343+ }
344+ bufs = append(bufs, b)
345+ }
346+ if err = r.ImportTargets(bufs); err != nil {
231 return err347 return err
232 }348 }
233- o.planes, o.imageMod = len(bufs[0].Planes), bufs[0].Modifier349+ return o.showBufs(r, bufs, allocGBM)
234- o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Int("planes", len(bufs[0].Planes)).Msg("zero-copy output")350+}
235- return nil351+
352+// dropImages frees the output images: their framebuffers, the GBM buffers
353+// and the renderer targets built over them (they would keep the memory
354+// alive after the buffers are gone). It is a no-op when there are none.
355+func (o *Output) dropImages(r ports.Renderer) {
356+ if len(o.images) > 0 {
357+ _ = r.ImportTargets(nil)
358+ }
359+ o.freeImages()
236 }360 }
237 361
238-// freeImages removes the output images' framebuffers.362+// freeImages removes the output images' framebuffers and frees the images
363+// GBM allocated. Callers that still have a renderer use dropImages: this
364+// alone leaves the renderer's targets over the freed images (the renderer
365+// drops them when it closes, before Close frees the output's images).
239 func (o *Output) freeImages() {366 func (o *Output) freeImages() {
240 o.front.forget()367 o.front.forget()
241 o.forgetOverlayProbes()368 o.forgetOverlayProbes()
@@ -245,4 +372,8 @@ func (o *Output) freeImages() {
245 }372 }
246 o.fbs[i] = 0373 o.fbs[i] = 0
247 }374 }
375+ for _, img := range o.images {
376+ img.close()
377+ }
378+ o.images = nil
248 }379 }
M internal/adapters/drm/output.go +43 -13
@@ -197,10 +197,26 @@ type Output struct {
197197 protectNotBefore time.Time
198198 // kind is how the images were made.
199199 kind imageKind
200- // planes is the memory plane count of the last exported images.
200+ // planes is the memory plane count of the output images.
201201 planes int
202- // imageMod is the modifier of the exported images (0: linear).
202+ // imageMod is the modifier of the output images (0: linear).
203203 imageMod uint64
204+ // openAlloc opens the GBM image allocator, once per output (nil: GBM is
205+ // not used); alloc is it, allocErr why it failed. images are the GBM
206+ // images alive while they are shown.
207+ openAlloc func() (imageAllocator, error)
208+ alloc imageAllocator
209+ allocErr error
210+ images []imageBuffer
211+ // cpuFences: the driver mishandles IN_FENCE_FD (fencegate.go), so
212+ // commits wait for their fences on the CPU instead.
213+ cpuFences bool
214+ // fencePoll polls client fences; fenceWarnAt is when a fence wait
215+ // timeout was last warned about.
216+ // fenceLimit overrides fenceWaitLimit (zero: no override; tests).
217+ fencePoll fencePoll
218+ fenceWarnAt time.Time
219+ fenceLimit time.Duration
204220 // formats receives the direct scanout formats after each modeset;
205221 // sampled and device are what they are built from (Want).
206222 formats chan<- ports.OutputFormats
@@ -222,7 +238,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransf
222238 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
223239 log := card.log
224240 pipe := slices.Index(card.crtcs, crtc)
225- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, renderLog: card.renderLog, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), ping: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
241+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, renderLog: card.renderLog, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, cpuFences: card.cpuFences, openAlloc: func() (imageAllocator, error) { return openGBM(card.fd) }, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), ping: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
226242 var err error
227243 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
228244 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -638,6 +654,17 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
638654 // (overlay) say. A frame that changes a plane's colour state is never async:
639655 // only FB_ID may change in an async commit.
640656 func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
657+ // Where fences are waited for on the CPU, once per commit and before
658+ // any state is read: the retries below go through commitRect. The
659+ // fence is our own render's, or a client's that already signalled
660+ // (clientFenceReady).
661+ o.waitFences(fence)
662+ return o.commitRect(fb, fence, async, vrr, f, ov, rect, pc)
663+}
664+
665+// commitRect is commitWithRect once the fence was waited for; its retries
666+// call it again, without waiting again.
667+func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
641668 o.observeSecurity()
642669 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
643670 return errSecurityScene
@@ -672,9 +699,7 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
672699 }
673700 }
674701 o.contentProps(req)
675- if fence != nil {
676- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
677- }
702+ o.fenceProp(req, o.primary, fence)
678703 // Admission linearizes at this last defensive snapshot. An already
679704 // admitted native ioctl can complete, but precedes the black proof.
680705 o.observeSecurity()
@@ -691,20 +716,20 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
691716 return errColorRefused
692717 }
693718 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
694- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
719+ return o.commitRect(fb, fence, false, vrr, f, ov, rect, pc)
695720 }
696721 // The same frame without the connector hint tells whether the hint
697722 // is the cause.
698723 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.frameTest(fb, fence, vrr, cur, ov, rect, pc, false) {
699724 o.log.Warn().Err(err).Msg("content type refused; disabled")
700725 o.contentProp = 0
701- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
726+ return o.commitRect(fb, fence, false, vrr, f, ov, rect, pc)
702727 }
703728 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
704729 // Retry without turning VRR on: if that passes, the driver
705730 // refuses VRR on this output. The overlay stays: the composed
706731 // image left its window out.
707- if o.commitWithRect(fb, fence, false, false, f, ov, rect, pc) == nil {
732+ if o.commitRect(fb, fence, false, false, f, ov, rect, pc) == nil {
708733 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
709734 o.vrrProp = 0
710735 return nil
@@ -767,9 +792,7 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
767792 if withContent {
768793 o.contentProps(req)
769794 }
770- if fence != nil {
771- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
772- }
795+ o.fenceProp(req, o.primary, fence)
773796 return o.k.commit(req, atomicTestOnly, 0) == nil
774797 }
775798
@@ -912,7 +935,7 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
912935 // from now on.
913936 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
914937 cfb.noAsync = true
915- err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
938+ err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
916939 }
917940 if errors.Is(err, errColorRefused) {
918941 // KMS takes the buffer without the pipeline: compose it; the
@@ -1022,6 +1045,10 @@ func (o *Output) Close() {
10221045 o.cursor.free(o.k)
10231046 }
10241047 o.freeImages()
1048+ if o.alloc != nil {
1049+ o.alloc.close()
1050+ o.alloc = nil
1051+ }
10251052 o.frame.endPending()
10261053 o.dropRead()
10271054 o.shown, o.queued = 0, 0
@@ -1498,6 +1525,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14981525 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
14991526 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
15001527 ev = ev.Int("state_commits", fs.stateCommits).Int("state_carried", fs.stateCarried)
1528+ if fs.fenceTimeouts > 0 {
1529+ ev = ev.Int("fence_timeouts", fs.fenceTimeouts)
1530+ }
15011531 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())
15021532 if o.traceFlips {
15031533 ev = ev.Int("late_fences", fs.lateFences)
@@ -197,10 +197,26 @@ type Output struct {
197 protectNotBefore time.Time197 protectNotBefore time.Time
198 // kind is how the images were made.198 // kind is how the images were made.
199 kind imageKind199 kind imageKind
200- // planes is the memory plane count of the last exported images.200+ // planes is the memory plane count of the output images.
201 planes int201 planes int
202- // imageMod is the modifier of the exported images (0: linear).202+ // imageMod is the modifier of the output images (0: linear).
203 imageMod uint64203 imageMod uint64
204+ // openAlloc opens the GBM image allocator, once per output (nil: GBM is
205+ // not used); alloc is it, allocErr why it failed. images are the GBM
206+ // images alive while they are shown.
207+ openAlloc func() (imageAllocator, error)
208+ alloc imageAllocator
209+ allocErr error
210+ images []imageBuffer
211+ // cpuFences: the driver mishandles IN_FENCE_FD (fencegate.go), so
212+ // commits wait for their fences on the CPU instead.
213+ cpuFences bool
214+ // fencePoll polls client fences; fenceWarnAt is when a fence wait
215+ // timeout was last warned about.
216+ // fenceLimit overrides fenceWaitLimit (zero: no override; tests).
217+ fencePoll fencePoll
218+ fenceWarnAt time.Time
219+ fenceLimit time.Duration
204 // formats receives the direct scanout formats after each modeset;220 // formats receives the direct scanout formats after each modeset;
205 // sampled and device are what they are built from (Want).221 // sampled and device are what they are built from (Want).
206 formats chan<- ports.OutputFormats222 formats chan<- ports.OutputFormats
@@ -222,7 +238,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransf
222 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {238 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
223 log := card.log239 log := card.log
224 pipe := slices.Index(card.crtcs, crtc)240 pipe := slices.Index(card.crtcs, crtc)
225- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, renderLog: card.renderLog, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), ping: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}241+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, renderLog: card.renderLog, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, cpuFences: card.cpuFences, openAlloc: func() (imageAllocator, error) { return openGBM(card.fd) }, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), ping: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
226 var err error242 var err error
227 if o.saved, err = getCrtc(card.fd, crtc); err != nil {243 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
228 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")244 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -638,6 +654,17 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
638 // (overlay) say. A frame that changes a plane's colour state is never async:654 // (overlay) say. A frame that changes a plane's colour state is never async:
639 // only FB_ID may change in an async commit.655 // only FB_ID may change in an async commit.
640 func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {656 func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
657+ // Where fences are waited for on the CPU, once per commit and before
658+ // any state is read: the retries below go through commitRect. The
659+ // fence is our own render's, or a client's that already signalled
660+ // (clientFenceReady).
661+ o.waitFences(fence)
662+ return o.commitRect(fb, fence, async, vrr, f, ov, rect, pc)
663+}
664+
665+// commitRect is commitWithRect once the fence was waited for; its retries
666+// call it again, without waiting again.
667+func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
641 o.observeSecurity()668 o.observeSecurity()
642 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {669 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
643 return errSecurityScene670 return errSecurityScene
@@ -672,9 +699,7 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
672 }699 }
673 }700 }
674 o.contentProps(req)701 o.contentProps(req)
675- if fence != nil {702+ o.fenceProp(req, o.primary, fence)
676- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
677- }
678 // Admission linearizes at this last defensive snapshot. An already703 // Admission linearizes at this last defensive snapshot. An already
679 // admitted native ioctl can complete, but precedes the black proof.704 // admitted native ioctl can complete, but precedes the black proof.
680 o.observeSecurity()705 o.observeSecurity()
@@ -691,20 +716,20 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
691 return errColorRefused716 return errColorRefused
692 }717 }
693 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {718 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
694- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)719+ return o.commitRect(fb, fence, false, vrr, f, ov, rect, pc)
695 }720 }
696 // The same frame without the connector hint tells whether the hint721 // The same frame without the connector hint tells whether the hint
697 // is the cause.722 // is the cause.
698 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.frameTest(fb, fence, vrr, cur, ov, rect, pc, false) {723 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.frameTest(fb, fence, vrr, cur, ov, rect, pc, false) {
699 o.log.Warn().Err(err).Msg("content type refused; disabled")724 o.log.Warn().Err(err).Msg("content type refused; disabled")
700 o.contentProp = 0725 o.contentProp = 0
701- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)726+ return o.commitRect(fb, fence, false, vrr, f, ov, rect, pc)
702 }727 }
703 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {728 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
704 // Retry without turning VRR on: if that passes, the driver729 // Retry without turning VRR on: if that passes, the driver
705 // refuses VRR on this output. The overlay stays: the composed730 // refuses VRR on this output. The overlay stays: the composed
706 // image left its window out.731 // image left its window out.
707- if o.commitWithRect(fb, fence, false, false, f, ov, rect, pc) == nil {732+ if o.commitRect(fb, fence, false, false, f, ov, rect, pc) == nil {
708 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")733 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
709 o.vrrProp = 0734 o.vrrProp = 0
710 return nil735 return nil
@@ -767,9 +792,7 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
767 if withContent {792 if withContent {
768 o.contentProps(req)793 o.contentProps(req)
769 }794 }
770- if fence != nil {795+ o.fenceProp(req, o.primary, fence)
771- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
772- }
773 return o.k.commit(req, atomicTestOnly, 0) == nil796 return o.k.commit(req, atomicTestOnly, 0) == nil
774 }797 }
775 798
@@ -912,7 +935,7 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
912 // from now on.935 // from now on.
913 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")936 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
914 cfb.noAsync = true937 cfb.noAsync = true
915- err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)938+ err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
916 }939 }
917 if errors.Is(err, errColorRefused) {940 if errors.Is(err, errColorRefused) {
918 // KMS takes the buffer without the pipeline: compose it; the941 // KMS takes the buffer without the pipeline: compose it; the
@@ -1022,6 +1045,10 @@ func (o *Output) Close() {
1022 o.cursor.free(o.k)1045 o.cursor.free(o.k)
1023 }1046 }
1024 o.freeImages()1047 o.freeImages()
1048+ if o.alloc != nil {
1049+ o.alloc.close()
1050+ o.alloc = nil
1051+ }
1025 o.frame.endPending()1052 o.frame.endPending()
1026 o.dropRead()1053 o.dropRead()
1027 o.shown, o.queued = 0, 01054 o.shown, o.queued = 0, 0
@@ -1498,6 +1525,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1498 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))1525 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
1499 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))1526 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
1500 ev = ev.Int("state_commits", fs.stateCommits).Int("state_carried", fs.stateCarried)1527 ev = ev.Int("state_commits", fs.stateCommits).Int("state_carried", fs.stateCarried)
1528+ if fs.fenceTimeouts > 0 {
1529+ ev = ev.Int("fence_timeouts", fs.fenceTimeouts)
1530+ }
1501 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())1531 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())
1502 if o.traceFlips {1532 if o.traceFlips {
1503 ev = ev.Int("late_fences", fs.lateFences)1533 ev = ev.Int("late_fences", fs.lateFences)
M internal/adapters/drm/overlay.go +2 -4
@@ -227,10 +227,8 @@ func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
227227 req.set(p.id, p.prop("CRTC_Y"), uint64(int64(ov.rect.Y)))
228228 req.set(p.id, p.prop("CRTC_W"), uint64(ov.rect.W))
229229 req.set(p.id, p.prop("CRTC_H"), uint64(ov.rect.H))
230- if ov.acquire != nil {
231- // KMS waits for the client's GPU work before scanning it out.
232- req.set(p.id, p.prop("IN_FENCE_FD"), uint64(ov.acquire.Fd()))
233- }
230+ // KMS waits for the client's GPU work before scanning it out.
231+ o.fenceProp(req, p, ov.acquire)
234232 }
235233
236234 // close releases the frame's duplicate of the acquire fence.
@@ -227,10 +227,8 @@ func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
227 req.set(p.id, p.prop("CRTC_Y"), uint64(int64(ov.rect.Y)))227 req.set(p.id, p.prop("CRTC_Y"), uint64(int64(ov.rect.Y)))
228 req.set(p.id, p.prop("CRTC_W"), uint64(ov.rect.W))228 req.set(p.id, p.prop("CRTC_W"), uint64(ov.rect.W))
229 req.set(p.id, p.prop("CRTC_H"), uint64(ov.rect.H))229 req.set(p.id, p.prop("CRTC_H"), uint64(ov.rect.H))
230- if ov.acquire != nil {230+ // KMS waits for the client's GPU work before scanning it out.
231- // KMS waits for the client's GPU work before scanning it out.231+ o.fenceProp(req, p, ov.acquire)
232- req.set(p.id, p.prop("IN_FENCE_FD"), uint64(ov.acquire.Fd()))
233- }
234 }232 }
235 233
236 // close releases the frame's duplicate of the acquire fence.234 // close releases the frame's duplicate of the acquire fence.
M internal/adapters/drm/protection_retry_test.go +1 -0
@@ -248,6 +248,7 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
248248 errs = []error{nil, errno} // HDR test commit, then the disable
249249 }
250250 o, k, _ := testOutput(t, errs...)
251+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
251252 o.cursor = nil
252253 o.protected = true
253254 o.hdr.cap = hdrCapability{Capable: true}
@@ -248,6 +248,7 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
248 errs = []error{nil, errno} // HDR test commit, then the disable248 errs = []error{nil, errno} // HDR test commit, then the disable
249 }249 }
250 o, k, _ := testOutput(t, errs...)250 o, k, _ := testOutput(t, errs...)
251+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
251 o.cursor = nil252 o.cursor = nil
252 o.protected = true253 o.protected = true
253 o.hdr.cap = hdrCapability{Capable: true}254 o.hdr.cap = hdrCapability{Capable: true}
M internal/adapters/drm/scanout.go +6 -0
M internal/adapters/drm/security_recovery_test.go +1 -0
M internal/adapters/drm/security_run_test.go +1 -0
M internal/adapters/drm/security_startup_test.go +1 -0
M internal/adapters/drm/vrr.go +3 -2
M internal/adapters/headless/output.go +4 -53
M internal/adapters/headless/output_test.go +0 -171
M internal/adapters/vulkan/renderer.go +3 -0
M internal/adapters/vulkan/targets.go +208 -8
M internal/adapters/vulkan/targets_test.go +172 -0
A internal/app/gbm_targets_test.go +151 -0
M internal/mocks/ports/devices_mock.go +110 -0
M internal/ports/devices.go +20 -3
M packaging/aur/neferwl-git/PKGBUILD +2 -2
M tools/demo/Dockerfile +15 -24
M tools/demo/config +0 -5
D tools/demo/demo-entry +0 -9
D tools/demo/demo-hint-label +0 -76
M tools/demo/input.txt +1 -32
D tools/demo/mousepad.keyfile +0 -6
M tools/demo/record.sh +42 -107
A tools/demo/site/index.html +13 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close