detached 92ab9ad

perf: faster game frame path (#59)

* build(deps): use purego-vulkan with global priority bindings

* perf(sched): request realtime input and output scheduling

* perf(vulkan): request elevated queue priority with fallback

* fix(wayland): gate buffer commits on signalled fences

* fix(wayland): start buffer waiter without explicit sync

* perf(drm): scan out scaled fullscreen buffers and set content type

* refactor(perf): remove unused helper and simplify wait error

* fix(sched): retire locked realtime threads on exit

* fix(wayland): recover from invalid fences and skipped buffers

* fix(drm): retest scanout scaling after modeset

* test(wayland): use an unowned fd for the poll error case

* fix(wayland): retry the implicit fence poll after a signal

* fix(render): log the Vulkan queue priority through the render logger

* build(deps): use purego-vulkan v0.6.0
commit
92ab9adf8910107eb6365617bcf45197ae78862e
author
bnema · · 2026-09-29 05:55 UTC
committer
GitHub · · 2026-09-29 05:55 UTC
parent
bfed184
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
32 files · +919 -74
M README.md +1 -1
@@ -61,7 +61,7 @@ These protocols and kernel features cut copies, waits and latency between the ap
6161 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.
6262 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.
6363 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.
64-- **Input.** Relative pointer, pointer constraints and keyboard shortcuts inhibit for games. Input at 1 kHz and more never waits on rendering, and the cursor has its own hardware plane.
64+- **Input.** Relative pointer, pointer constraints and keyboard shortcuts inhibit for games. Input at 1 kHz and more never waits on rendering, and the cursor has its own hardware plane. With CAP_SYS_NICE, input and output threads request real-time scheduling.
6565 - **X11 games.** Steam and X11-only games run through xwayland-satellite, started on the first X11 connection. Wine with its Wayland driver does not need it.
6666
6767 ## Hardware support
@@ -61,7 +61,7 @@ These protocols and kernel features cut copies, waits and latency between the ap
61 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.61 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.
62 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.62 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.
63 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.63 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.
64-- **Input.** Relative pointer, pointer constraints and keyboard shortcuts inhibit for games. Input at 1 kHz and more never waits on rendering, and the cursor has its own hardware plane.64+- **Input.** Relative pointer, pointer constraints and keyboard shortcuts inhibit for games. Input at 1 kHz and more never waits on rendering, and the cursor has its own hardware plane. With CAP_SYS_NICE, input and output threads request real-time scheduling.
65 - **X11 games.** Steam and X11-only games run through xwayland-satellite, started on the first X11 connection. Wine with its Wayland driver does not need it.65 - **X11 games.** Steam and X11-only games run through xwayland-satellite, started on the first X11 connection. Wine with its Wayland driver does not need it.
66 66
67 ## Hardware support67 ## Hardware support
M docs/config.md +1 -0
@@ -49,6 +49,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4949 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
5050 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
5151 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
52+| `performance.realtime` | `on` | Request real-time scheduling for output and input threads |
5253 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
5354 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
5455
@@ -49,6 +49,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
49 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |49 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
50 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |50 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
51 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |51 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
52+| `performance.realtime` | `on` | Request real-time scheduling for output and input threads |
52 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |53 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
53 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |54 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
54 55
M docs/performance.md +2 -0
@@ -9,6 +9,8 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
1010 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1111
12+Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE: `sudo setcap cap_sys_nice+ep /usr/bin/neferwl`. Without it NeferWL runs at normal priority.
13+
1214 ## Guards
1315
1416 `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
@@ -9,6 +9,8 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
10 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.10 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
11 11
12+Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE: `sudo setcap cap_sys_nice+ep /usr/bin/neferwl`. Without it NeferWL runs at normal priority.
13+
12 ## Guards14 ## Guards
13 15
14 `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.16 `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
M examples/config +3 -0
@@ -91,6 +91,9 @@ render.direct-scanout = on
9191 render.tearing = on
9292 render.vrr = on
9393
94+# Run output and input threads with SCHED_RR when permitted.
95+performance.realtime = on
96+
9497 # log.level: debug, info, warn or error. log.debug: components or all.
9598 log.level = info
9699 log.debug =
@@ -91,6 +91,9 @@ render.direct-scanout = on
91 render.tearing = on91 render.tearing = on
92 render.vrr = on92 render.vrr = on
93 93
94+# Run output and input threads with SCHED_RR when permitted.
95+performance.realtime = on
96+
94 # log.level: debug, info, warn or error. log.debug: components or all.97 # log.level: debug, info, warn or error. log.debug: components or all.
95 log.level = info98 log.level = info
96 log.debug =99 log.debug =
M go.mod +1 -1
@@ -5,7 +5,7 @@ go 1.27
55 require (
66 github.com/bnema/purego v0.13.0-bnema.1
77 github.com/bnema/purego-libwayland v0.5.0
8- github.com/bnema/purego-vulkan v0.5.0
8+ github.com/bnema/purego-vulkan v0.6.0
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
1111 github.com/rs/zerolog v1.35.1
@@ -5,7 +5,7 @@ go 1.27
5 require (5 require (
6 github.com/bnema/purego v0.13.0-bnema.16 github.com/bnema/purego v0.13.0-bnema.1
7 github.com/bnema/purego-libwayland v0.5.07 github.com/bnema/purego-libwayland v0.5.0
8- github.com/bnema/purego-vulkan v0.5.08+ github.com/bnema/purego-vulkan v0.6.0
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
11 github.com/rs/zerolog v1.35.111 github.com/rs/zerolog v1.35.1
M go.sum +2 -2 generated file: collapsed
M internal/adapters/config/config.go +7 -0
@@ -135,6 +135,7 @@ func Defaults() ports.Config {
135135 c.Render.DirectScanout = true
136136 c.Render.Tearing = true
137137 c.Render.VRR = true
138+ c.Performance.Realtime = true
138139 c.Log.Level = "info"
139140 c.Log.Debug = []string{}
140141 return c
@@ -576,6 +577,12 @@ func set(c *ports.Config, key, v string) error {
576577 return err
577578 }
578579 c.Render.VRR = b
580+ case "performance.realtime":
581+ b, err := onOff(v)
582+ if err != nil {
583+ return err
584+ }
585+ c.Performance.Realtime = b
579586 case "log.level":
580587 if v != "debug" && v != "info" && v != "warn" && v != "error" {
581588 return fmt.Errorf("must be debug, info, warn or error")
@@ -135,6 +135,7 @@ func Defaults() ports.Config {
135 c.Render.DirectScanout = true135 c.Render.DirectScanout = true
136 c.Render.Tearing = true136 c.Render.Tearing = true
137 c.Render.VRR = true137 c.Render.VRR = true
138+ c.Performance.Realtime = true
138 c.Log.Level = "info"139 c.Log.Level = "info"
139 c.Log.Debug = []string{}140 c.Log.Debug = []string{}
140 return c141 return c
@@ -576,6 +577,12 @@ func set(c *ports.Config, key, v string) error {
576 return err577 return err
577 }578 }
578 c.Render.VRR = b579 c.Render.VRR = b
580+ case "performance.realtime":
581+ b, err := onOff(v)
582+ if err != nil {
583+ return err
584+ }
585+ c.Performance.Realtime = b
579 case "log.level":586 case "log.level":
580 if v != "debug" && v != "info" && v != "warn" && v != "error" {587 if v != "debug" && v != "info" && v != "warn" && v != "error" {
581 return fmt.Errorf("must be debug, info, warn or error")588 return fmt.Errorf("must be debug, info, warn or error")
M internal/adapters/config/config_test.go +14 -0
@@ -48,6 +48,20 @@ func TestDefaultsAndLoad(t *testing.T) {
4848 }
4949 }
5050
51+func TestRealtime(t *testing.T) {
52+ if !Defaults().Performance.Realtime {
53+ t.Fatal("realtime default disabled")
54+ }
55+ c, w := parseString(t, "performance.realtime = off\n")
56+ if c.Performance.Realtime || len(w) != 0 {
57+ t.Fatal(c.Performance, w)
58+ }
59+ c, w = parseString(t, "performance.realtime = invalid\n")
60+ if !c.Performance.Realtime || len(w) != 1 {
61+ t.Fatal(c.Performance, w)
62+ }
63+}
64+
5165 func TestFollowMove(t *testing.T) {
5266 c, w := parseString(t, "focus.follow-move = on\n")
5367 if !c.Focus.FollowMove || len(w) != 0 {
@@ -48,6 +48,20 @@ func TestDefaultsAndLoad(t *testing.T) {
48 }48 }
49 }49 }
50 50
51+func TestRealtime(t *testing.T) {
52+ if !Defaults().Performance.Realtime {
53+ t.Fatal("realtime default disabled")
54+ }
55+ c, w := parseString(t, "performance.realtime = off\n")
56+ if c.Performance.Realtime || len(w) != 0 {
57+ t.Fatal(c.Performance, w)
58+ }
59+ c, w = parseString(t, "performance.realtime = invalid\n")
60+ if !c.Performance.Realtime || len(w) != 1 {
61+ t.Fatal(c.Performance, w)
62+ }
63+}
64+
51 func TestFollowMove(t *testing.T) {65 func TestFollowMove(t *testing.T) {
52 c, w := parseString(t, "focus.follow-move = on\n")66 c, w := parseString(t, "focus.follow-move = on\n")
53 if !c.Focus.FollowMove || len(w) != 0 {67 if !c.Focus.FollowMove || len(w) != 0 {
M internal/adapters/drm/frame_submit.go +2 -1
@@ -26,10 +26,11 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
2626 // shows is what the frame puts on screen: presentation feedback of
2727 // windows it does not draw is discarded, not presented.
2828 f := pendingFrame{shows: o.shownBy(scene, seen)}
29+ o.wantContent(scene, surfaces)
2930 decision := o.decideFrame(scene, surfaces, len(requests) > 0)
3031 if decision.fb != 0 {
3132 c := decision.content
32- if direct, err = o.commitScanout(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}); direct {
33+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
3334 return true, err
3435 }
3536 decision = o.composeFrame(scene, surfaces)
@@ -26,10 +26,11 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
26 // shows is what the frame puts on screen: presentation feedback of26 // shows is what the frame puts on screen: presentation feedback of
27 // windows it does not draw is discarded, not presented.27 // windows it does not draw is discarded, not presented.
28 f := pendingFrame{shows: o.shownBy(scene, seen)}28 f := pendingFrame{shows: o.shownBy(scene, seen)}
29+ o.wantContent(scene, surfaces)
29 decision := o.decideFrame(scene, surfaces, len(requests) > 0)30 decision := o.decideFrame(scene, surfaces, len(requests) > 0)
30 if decision.fb != 0 {31 if decision.fb != 0 {
31 c := decision.content32 c := decision.content
32- if direct, err = o.commitScanout(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}); direct {33+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
33 return true, err34 return true, err
34 }35 }
35 decision = o.composeFrame(scene, surfaces)36 decision = o.composeFrame(scene, surfaces)
A internal/adapters/drm/game_performance_test.go +103 -0
@@ -0,0 +1,103 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/mock"
10+ "golang.org/x/sys/unix"
11+)
12+
13+func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
14+ o, _, commits := testOutput(t, unix.EINVAL)
15+ o.cursor = nil
16+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
17+ o.clientFBs[9] = &clientFB{fbID: 87}
18+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
19+ c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, Source: [4]float32{5, 2, 90, 46}, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
20+ var log bytes.Buffer
21+ o.log = zerowrap.New(zerowrap.Config{Output: &log})
22+ surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
23+ if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {
24+ t.Fatalf("fb %d reason %s", fb, o.reason)
25+ }
26+ if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly {
27+ t.Fatalf("test commits %+v", *commits)
28+ }
29+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["SRC_X"]); v != 5<<16 {
30+ t.Fatalf("source x: %d", v)
31+ }
32+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["SRC_W"]); v != 90<<16 {
33+ t.Fatalf("source width: %d", v)
34+ }
35+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 {
36+ t.Fatalf("destination width: %d", v)
37+ }
38+ if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {
39+ t.Fatal("refused geometry retried")
40+ }
41+ if !bytes.Contains(log.Bytes(), []byte("scale_refused")) {
42+ t.Fatalf("refusal not logged: %s", log.String())
43+ }
44+}
45+
46+func TestModesetInvalidatesScaleDecision(t *testing.T) {
47+ o, k, commits := testOutput(t)
48+ o.cursor = nil
49+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
50+ fb := &clientFB{fbID: 87}
51+ o.clientFBs[9] = fb
52+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
53+ c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
54+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
55+ if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {
56+ t.Fatalf("initial scale: %d, commits: %d", got, len(*commits))
57+ }
58+ k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once()
59+ if err := o.modeset(); err != nil {
60+ t.Fatal(err)
61+ }
62+ if fb.scaleTestedOK || fb.scaleRefused {
63+ t.Fatal("cached scale survived modeset")
64+ }
65+ if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {
66+ t.Fatalf("scale not retested: fb %d commits %+v", got, *commits)
67+ }
68+}
69+
70+func TestContentTypeFrameAndReset(t *testing.T) {
71+ o, _, commits := testOutput(t)
72+ o.cursor = nil
73+ o.contentProp = 81
74+ o.contentValues = [5]uint64{0, 1, 2, 3, 4}
75+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
76+ o.wantContent(s, map[ports.WindowID]ports.SurfaceContent{1: {ContentType: ports.ContentGame}})
77+ for i := 0; i < 2; i++ {
78+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ if v, ok := (*commits)[0].req.value(tConn, 81); !ok || v != 4 {
83+ t.Fatalf("game hint: %d %v", v, ok)
84+ }
85+ if _, ok := (*commits)[1].req.value(tConn, 81); ok {
86+ t.Fatal("hint repeated")
87+ }
88+ o.wantContent(ports.Scene{}, nil)
89+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
90+ t.Fatal(err)
91+ }
92+ if v, ok := (*commits)[2].req.value(tConn, 81); !ok || v != 0 {
93+ t.Fatalf("reset hint: %d %v", v, ok)
94+ }
95+ o.contentProp = 0
96+ o.wantContent(s, map[ports.WindowID]ports.SurfaceContent{1: {ContentType: ports.ContentGame}})
97+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
98+ t.Fatal(err)
99+ }
100+ if _, ok := (*commits)[3].req.value(tConn, 81); ok {
101+ t.Fatal("absent property set")
102+ }
103+}
@@ -0,0 +1,103 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/mock"
10+ "golang.org/x/sys/unix"
11+)
12+
13+func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
14+ o, _, commits := testOutput(t, unix.EINVAL)
15+ o.cursor = nil
16+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
17+ o.clientFBs[9] = &clientFB{fbID: 87}
18+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
19+ c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, Source: [4]float32{5, 2, 90, 46}, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
20+ var log bytes.Buffer
21+ o.log = zerowrap.New(zerowrap.Config{Output: &log})
22+ surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
23+ if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {
24+ t.Fatalf("fb %d reason %s", fb, o.reason)
25+ }
26+ if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly {
27+ t.Fatalf("test commits %+v", *commits)
28+ }
29+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["SRC_X"]); v != 5<<16 {
30+ t.Fatalf("source x: %d", v)
31+ }
32+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["SRC_W"]); v != 90<<16 {
33+ t.Fatalf("source width: %d", v)
34+ }
35+ if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 {
36+ t.Fatalf("destination width: %d", v)
37+ }
38+ if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {
39+ t.Fatal("refused geometry retried")
40+ }
41+ if !bytes.Contains(log.Bytes(), []byte("scale_refused")) {
42+ t.Fatalf("refusal not logged: %s", log.String())
43+ }
44+}
45+
46+func TestModesetInvalidatesScaleDecision(t *testing.T) {
47+ o, k, commits := testOutput(t)
48+ o.cursor = nil
49+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
50+ fb := &clientFB{fbID: 87}
51+ o.clientFBs[9] = fb
52+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
53+ c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
54+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
55+ if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {
56+ t.Fatalf("initial scale: %d, commits: %d", got, len(*commits))
57+ }
58+ k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once()
59+ if err := o.modeset(); err != nil {
60+ t.Fatal(err)
61+ }
62+ if fb.scaleTestedOK || fb.scaleRefused {
63+ t.Fatal("cached scale survived modeset")
64+ }
65+ if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {
66+ t.Fatalf("scale not retested: fb %d commits %+v", got, *commits)
67+ }
68+}
69+
70+func TestContentTypeFrameAndReset(t *testing.T) {
71+ o, _, commits := testOutput(t)
72+ o.cursor = nil
73+ o.contentProp = 81
74+ o.contentValues = [5]uint64{0, 1, 2, 3, 4}
75+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
76+ o.wantContent(s, map[ports.WindowID]ports.SurfaceContent{1: {ContentType: ports.ContentGame}})
77+ for i := 0; i < 2; i++ {
78+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ if v, ok := (*commits)[0].req.value(tConn, 81); !ok || v != 4 {
83+ t.Fatalf("game hint: %d %v", v, ok)
84+ }
85+ if _, ok := (*commits)[1].req.value(tConn, 81); ok {
86+ t.Fatal("hint repeated")
87+ }
88+ o.wantContent(ports.Scene{}, nil)
89+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
90+ t.Fatal(err)
91+ }
92+ if v, ok := (*commits)[2].req.value(tConn, 81); !ok || v != 0 {
93+ t.Fatalf("reset hint: %d %v", v, ok)
94+ }
95+ o.contentProp = 0
96+ o.wantContent(s, map[ports.WindowID]ports.SurfaceContent{1: {ContentType: ports.ContentGame}})
97+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
98+ t.Fatal(err)
99+ }
100+ if _, ok := (*commits)[3].req.value(tConn, 81); ok {
101+ t.Fatal("absent property set")
102+ }
103+}
M internal/adapters/drm/output.go +117 -16
@@ -75,6 +75,7 @@ type Output struct {
7575 scanout bool
7676 clientFBs map[uint64]*clientFB
7777 shown, queued uint64
78+ planeRect planeRect
7879 reason string // why the last frame was composed ("" = scanout)
7980 unsent []ports.OutputPresented
8081 // Last immutable snapshot sent in a report. Never mutate it: wayland may
@@ -96,6 +97,10 @@ type Output struct {
9697 vrrProp uint32
9798 vrrOn bool
9899 vrrGame bool
100+ contentProp uint32
101+ contentValues [5]uint64
102+ contentValue uint64
103+ contentWanted uint64
99104 composedSince time.Time
100105 // lastFrame is when the last frame was committed. cursorHeld: a cursor
101106 // move waits for the next frame (see cursorWaits).
@@ -252,6 +257,9 @@ func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int)
252257 o.connCrtc = uint32(np["CRTC_ID"][0])
253258 // The connector's property metadata is needed for enum/range capability.
254259 o.hdrProps = readConnectorHDRProps(fd, np)
260+ o.contentProp, o.contentValues = readContentTypeProp(fd, np)
261+ o.contentValue = np["content type"][1]
262+ o.contentWanted = o.contentValue
255263 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {
256264 return errors.New("crtc or connector lacks atomic properties")
257265 }
@@ -336,18 +344,19 @@ func (o *Output) Height() int { return int(o.mode.VDisplay) }
336344
337345 // primaryProps shows fb full screen on the primary plane.
338346 func (o *Output) primaryProps(req *atomicReq, fb uint32) {
347+ o.primaryRectProps(req, fb, fullPlaneRect(o.Width(), o.Height()))
348+}
349+
350+func (o *Output) primaryRectProps(req *atomicReq, fb uint32, rect planeRect) {
339351 p := o.primary
340- w, h := uint64(o.Width()), uint64(o.Height())
341352 req.set(p.id, p.prop("FB_ID"), uint64(fb))
342353 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
343- req.set(p.id, p.prop("SRC_X"), 0)
344- req.set(p.id, p.prop("SRC_Y"), 0)
345- req.set(p.id, p.prop("SRC_W"), w<<16)
346- req.set(p.id, p.prop("SRC_H"), h<<16)
347- req.set(p.id, p.prop("CRTC_X"), 0)
348- req.set(p.id, p.prop("CRTC_Y"), 0)
349- req.set(p.id, p.prop("CRTC_W"), w)
350- req.set(p.id, p.prop("CRTC_H"), h)
354+ for i, name := range [...]string{"SRC_X", "SRC_Y", "SRC_W", "SRC_H"} {
355+ req.set(p.id, p.prop(name), rect.src[i])
356+ }
357+ for i, name := range [...]string{"CRTC_X", "CRTC_Y", "CRTC_W", "CRTC_H"} {
358+ req.set(p.id, p.prop(name), rect.crtc[i])
359+ }
351360 }
352361
353362 // modeset shows the front image with the output's mode, turning off every
@@ -365,12 +374,18 @@ func (o *Output) modeset() error {
365374 // Only now is nothing of ours pending or on screen: on failure the
366375 // previous buffers may still show.
367376 o.frame.resetAfterModeset()
377+ // Plane scaling capabilities may change after a modeset or VT resume.
378+ for _, fb := range o.clientFBs {
379+ fb.scaleTestedOK, fb.scaleRefused = false, false
380+ }
368381 o.shown, o.queued = 0, 0
369382 if o.modeBlob != 0 {
370383 _ = o.k.destroyBlob(o.modeBlob)
371384 }
372385 o.modeBlob = blob
373386 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
387+ o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
388+ o.planeRect = fullPlaneRect(o.Width(), o.Height())
374389 if o.cursor != nil {
375390 o.cursor.applied = cursorState{}
376391 o.cursor.screen, o.cursor.flying = 0, false
@@ -395,10 +410,12 @@ func (o *Output) powerOff() error {
395410 req := &atomicReq{}
396411 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
397412 req.set(o.crtc, o.vrrProp, 0)
413+ req.set(o.conn.id, o.contentProp, o.contentValues[0])
398414 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
399415 return fmt.Errorf("power off: %w", err)
400416 }
401417 o.off, o.vrrOn, o.vrrGame = true, false, false
418+ o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
402419 o.log.Info().Str("connector", o.conn.name).Msg("power off")
403420 // An inactive output offers neither HDR nor direct scanout.
404421 o.sendFormats()
@@ -481,6 +498,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
481498 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
482499 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
483500 req.set(o.crtc, o.vrrProp, 0)
501+ req.set(o.conn.id, o.contentProp, o.contentValues[0])
484502 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
485503 o.hdrConnectorProps(req, o.hdrOn)
486504 o.primaryProps(req, o.fbs[1-o.back])
@@ -546,21 +564,59 @@ func (o *Output) wantVRR(game bool) bool {
546564 // be a tiled window on the overlay plane.
547565 func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
548566
567+// contentProps changes the connector hint only when it differs from the applied value.
568+func (o *Output) contentProps(req *atomicReq) {
569+ if o.contentProp != 0 && o.contentWanted != o.contentValue {
570+ req.set(o.conn.id, o.contentProp, o.contentWanted)
571+ }
572+}
573+
574+// wantContent chooses a content hint for the only visible fullscreen window.
575+func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
576+ o.contentWanted = o.contentValues[0]
577+ var full *ports.SceneWindow
578+ for i := range s.Windows {
579+ w := &s.Windows[i]
580+ if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
581+ continue
582+ }
583+ if !w.Fullscreen || !covers(&s, w) || full != nil {
584+ return
585+ }
586+ full = w
587+ }
588+ if full == nil {
589+ return
590+ }
591+ switch surfaces[full.ID].ContentType {
592+ case ports.ContentPhoto:
593+ o.contentWanted = o.contentValues[2]
594+ case ports.ContentVideo:
595+ o.contentWanted = o.contentValues[3]
596+ case ports.ContentGame:
597+ o.contentWanted = o.contentValues[4]
598+ }
599+}
600+
549601 // commitFrame flips fb in one commit with the cursor and VRR. An async
550602 // commit carries only the primary plane (kernel rule): a frame that must
551603 // also move the cursor or change VRR flips at vblank. fence is the
552604 // frame's GPU fence (nil: none); the caller keeps and closes it.
553605 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {
554- return o.commitWith(fb, fence, async, vrr, f, overlayWin{})
606+ return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()))
555607 }
556608
557609 // commitWith is commitFrame with ov on the overlay plane (zero: off).
558610 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {
611+ return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()))
612+}
613+
614+func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {
559615 cur := cursorState{}
560616 if o.cursor != nil {
561617 cur = o.cursor.desired()
562618 }
563- if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn) {
619+ if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) {
564620 async = false
565621 }
566622 req := &atomicReq{}
@@ -569,7 +625,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
569625 flags |= flipAsyncFlag
570626 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))
571627 } else {
572- o.primaryProps(req, fb)
628+ o.primaryRectProps(req, fb, rect)
573629 req.set(o.crtc, o.vrrProp, boolValue(vrr))
574630 if o.cursor != nil {
575631 o.cursor.props(req, o.crtc, cur)
@@ -578,19 +634,25 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
578634 o.overlayProps(req, ov)
579635 }
580636 }
637+ o.contentProps(req)
581638 if fence != nil {
582639 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
583640 }
584641 // The kernel takes its own reference on the fence.
585642 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
586643 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
587- return o.commitWith(fb, fence, false, vrr, f, ov)
644+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
645+ }
646+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.contentRefused(fb, fence, vrr, cur, ov, rect) {
647+ o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
648+ o.contentProp = 0
649+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
588650 }
589651 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
590652 // Retry without turning VRR on: if that passes, the driver
591653 // refuses VRR on this output. The overlay stays: the composed
592654 // image left its window out.
593- if o.commitWith(fb, fence, false, false, f, ov) == nil {
655+ if o.commitWithRect(fb, fence, false, false, f, ov, rect) == nil {
594656 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
595657 o.vrrProp = 0
596658 return nil
@@ -611,6 +673,8 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
611673 if !async {
612674 o.overlayOn = ov.buf
613675 }
676+ o.contentValue = o.contentWanted
677+ o.planeRect = rect
614678 o.lastFrame, o.cursorHeld = time.Now(), false
615679 f.frame, f.async = true, async
616680 f.fences = dupFences(fence, ov.acquire)
@@ -619,6 +683,23 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
619683 return nil
620684 }
621685
686+// contentRefused tests the same frame without the connector hint before disabling it.
687+func (o *Output) contentRefused(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect) bool {
688+ req := &atomicReq{}
689+ o.primaryRectProps(req, fb, rect)
690+ req.set(o.crtc, o.vrrProp, boolValue(vrr))
691+ if o.cursor != nil {
692+ o.cursor.props(req, o.crtc, cur)
693+ }
694+ if ov.buf != 0 || o.overlayOn != 0 {
695+ o.overlayProps(req, ov)
696+ }
697+ if fence != nil {
698+ req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
699+ }
700+ return o.k.commit(req, atomicTestOnly, 0) == nil
701+}
702+
622703 // cursorRefused reports, after a commit carrying the cursor failed with
623704 // EINVAL, whether the cursor plane was the cause; then the cursor is off
624705 // and the commit can be retried without it.
@@ -647,6 +728,7 @@ func (o *Output) commitState(vrr bool) error {
647728 if vrr != o.vrrOn {
648729 req.set(o.crtc, o.vrrProp, boolValue(vrr))
649730 }
731+ o.contentProps(req)
650732 if len(req.objs) == 0 {
651733 return nil
652734 }
@@ -658,6 +740,20 @@ func (o *Output) commitState(vrr bool) error {
658740 if cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
659741 return o.commitState(vrr)
660742 }
743+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) {
744+ without := &atomicReq{}
745+ if o.cursor != nil && cur != o.cursor.applied {
746+ o.cursor.props(without, o.crtc, cur)
747+ }
748+ if vrr != o.vrrOn {
749+ without.set(o.crtc, o.vrrProp, boolValue(vrr))
750+ }
751+ if len(without.objs) == 0 || o.k.commit(without, atomicTestOnly, 0) == nil {
752+ o.contentProp = 0
753+ o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
754+ return o.commitState(vrr)
755+ }
756+ }
661757 return err
662758 }
663759 if o.cursor != nil {
@@ -667,6 +763,7 @@ func (o *Output) commitState(vrr bool) error {
667763 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
668764 }
669765 o.vrrOn = vrr
766+ o.contentValue = o.contentWanted
670767 o.frame.begin(pendingFrame{}, time.Now())
671768 return nil
672769 }
@@ -693,6 +790,10 @@ func boolValue(b bool) uint64 {
693790 // commitScanout flips a client buffer. It reports false when KMS refused
694791 // the buffer: the frame must be composed.
695792 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
793+ return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()))
794+}
795+
796+func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect) (bool, error) {
696797 cfb := o.clientFBs[c.DMABuf.ID]
697798 // Drivers refuse async flips that change the format or modifier: the
698799 // flip from the composed image into scanout waits for vblank.
@@ -706,13 +807,13 @@ func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame
706807 if fence != nil {
707808 defer fence.Close()
708809 }
709- err := o.commitFrame(fb, fence, async, vrr, f)
810+ err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect)
710811 if err != nil && async && errors.Is(err, unix.EINVAL) {
711812 // Refused (e.g. not a fast update): this buffer flips at vblank
712813 // from now on.
713814 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
714815 cfb.noAsync = true
715- err = o.commitFrame(fb, fence, false, vrr, f)
816+ err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect)
716817 }
717818 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
718819 // KMS refuses this buffer on the plane: compose it instead.
@@ -75,6 +75,7 @@ type Output struct {
75 scanout bool75 scanout bool
76 clientFBs map[uint64]*clientFB76 clientFBs map[uint64]*clientFB
77 shown, queued uint6477 shown, queued uint64
78+ planeRect planeRect
78 reason string // why the last frame was composed ("" = scanout)79 reason string // why the last frame was composed ("" = scanout)
79 unsent []ports.OutputPresented80 unsent []ports.OutputPresented
80 // Last immutable snapshot sent in a report. Never mutate it: wayland may81 // Last immutable snapshot sent in a report. Never mutate it: wayland may
@@ -96,6 +97,10 @@ type Output struct {
96 vrrProp uint3297 vrrProp uint32
97 vrrOn bool98 vrrOn bool
98 vrrGame bool99 vrrGame bool
100+ contentProp uint32
101+ contentValues [5]uint64
102+ contentValue uint64
103+ contentWanted uint64
99 composedSince time.Time104 composedSince time.Time
100 // lastFrame is when the last frame was committed. cursorHeld: a cursor105 // lastFrame is when the last frame was committed. cursorHeld: a cursor
101 // move waits for the next frame (see cursorWaits).106 // move waits for the next frame (see cursorWaits).
@@ -252,6 +257,9 @@ func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int)
252 o.connCrtc = uint32(np["CRTC_ID"][0])257 o.connCrtc = uint32(np["CRTC_ID"][0])
253 // The connector's property metadata is needed for enum/range capability.258 // The connector's property metadata is needed for enum/range capability.
254 o.hdrProps = readConnectorHDRProps(fd, np)259 o.hdrProps = readConnectorHDRProps(fd, np)
260+ o.contentProp, o.contentValues = readContentTypeProp(fd, np)
261+ o.contentValue = np["content type"][1]
262+ o.contentWanted = o.contentValue
255 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {263 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {
256 return errors.New("crtc or connector lacks atomic properties")264 return errors.New("crtc or connector lacks atomic properties")
257 }265 }
@@ -336,18 +344,19 @@ func (o *Output) Height() int { return int(o.mode.VDisplay) }
336 344
337 // primaryProps shows fb full screen on the primary plane.345 // primaryProps shows fb full screen on the primary plane.
338 func (o *Output) primaryProps(req *atomicReq, fb uint32) {346 func (o *Output) primaryProps(req *atomicReq, fb uint32) {
347+ o.primaryRectProps(req, fb, fullPlaneRect(o.Width(), o.Height()))
348+}
349+
350+func (o *Output) primaryRectProps(req *atomicReq, fb uint32, rect planeRect) {
339 p := o.primary351 p := o.primary
340- w, h := uint64(o.Width()), uint64(o.Height())
341 req.set(p.id, p.prop("FB_ID"), uint64(fb))352 req.set(p.id, p.prop("FB_ID"), uint64(fb))
342 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))353 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
343- req.set(p.id, p.prop("SRC_X"), 0)354+ for i, name := range [...]string{"SRC_X", "SRC_Y", "SRC_W", "SRC_H"} {
344- req.set(p.id, p.prop("SRC_Y"), 0)355+ req.set(p.id, p.prop(name), rect.src[i])
345- req.set(p.id, p.prop("SRC_W"), w<<16)356+ }
346- req.set(p.id, p.prop("SRC_H"), h<<16)357+ for i, name := range [...]string{"CRTC_X", "CRTC_Y", "CRTC_W", "CRTC_H"} {
347- req.set(p.id, p.prop("CRTC_X"), 0)358+ req.set(p.id, p.prop(name), rect.crtc[i])
348- req.set(p.id, p.prop("CRTC_Y"), 0)359+ }
349- req.set(p.id, p.prop("CRTC_W"), w)
350- req.set(p.id, p.prop("CRTC_H"), h)
351 }360 }
352 361
353 // modeset shows the front image with the output's mode, turning off every362 // modeset shows the front image with the output's mode, turning off every
@@ -365,12 +374,18 @@ func (o *Output) modeset() error {
365 // Only now is nothing of ours pending or on screen: on failure the374 // Only now is nothing of ours pending or on screen: on failure the
366 // previous buffers may still show.375 // previous buffers may still show.
367 o.frame.resetAfterModeset()376 o.frame.resetAfterModeset()
377+ // Plane scaling capabilities may change after a modeset or VT resume.
378+ for _, fb := range o.clientFBs {
379+ fb.scaleTestedOK, fb.scaleRefused = false, false
380+ }
368 o.shown, o.queued = 0, 0381 o.shown, o.queued = 0, 0
369 if o.modeBlob != 0 {382 if o.modeBlob != 0 {
370 _ = o.k.destroyBlob(o.modeBlob)383 _ = o.k.destroyBlob(o.modeBlob)
371 }384 }
372 o.modeBlob = blob385 o.modeBlob = blob
373 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff386 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
387+ o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
388+ o.planeRect = fullPlaneRect(o.Width(), o.Height())
374 if o.cursor != nil {389 if o.cursor != nil {
375 o.cursor.applied = cursorState{}390 o.cursor.applied = cursorState{}
376 o.cursor.screen, o.cursor.flying = 0, false391 o.cursor.screen, o.cursor.flying = 0, false
@@ -395,10 +410,12 @@ func (o *Output) powerOff() error {
395 req := &atomicReq{}410 req := &atomicReq{}
396 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)411 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
397 req.set(o.crtc, o.vrrProp, 0)412 req.set(o.crtc, o.vrrProp, 0)
413+ req.set(o.conn.id, o.contentProp, o.contentValues[0])
398 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {414 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
399 return fmt.Errorf("power off: %w", err)415 return fmt.Errorf("power off: %w", err)
400 }416 }
401 o.off, o.vrrOn, o.vrrGame = true, false, false417 o.off, o.vrrOn, o.vrrGame = true, false, false
418+ o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
402 o.log.Info().Str("connector", o.conn.name).Msg("power off")419 o.log.Info().Str("connector", o.conn.name).Msg("power off")
403 // An inactive output offers neither HDR nor direct scanout.420 // An inactive output offers neither HDR nor direct scanout.
404 o.sendFormats()421 o.sendFormats()
@@ -481,6 +498,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
481 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))498 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
482 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))499 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
483 req.set(o.crtc, o.vrrProp, 0)500 req.set(o.crtc, o.vrrProp, 0)
501+ req.set(o.conn.id, o.contentProp, o.contentValues[0])
484 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))502 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
485 o.hdrConnectorProps(req, o.hdrOn)503 o.hdrConnectorProps(req, o.hdrOn)
486 o.primaryProps(req, o.fbs[1-o.back])504 o.primaryProps(req, o.fbs[1-o.back])
@@ -546,21 +564,59 @@ func (o *Output) wantVRR(game bool) bool {
546 // be a tiled window on the overlay plane.564 // be a tiled window on the overlay plane.
547 func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }565 func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
548 566
567+// contentProps changes the connector hint only when it differs from the applied value.
568+func (o *Output) contentProps(req *atomicReq) {
569+ if o.contentProp != 0 && o.contentWanted != o.contentValue {
570+ req.set(o.conn.id, o.contentProp, o.contentWanted)
571+ }
572+}
573+
574+// wantContent chooses a content hint for the only visible fullscreen window.
575+func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
576+ o.contentWanted = o.contentValues[0]
577+ var full *ports.SceneWindow
578+ for i := range s.Windows {
579+ w := &s.Windows[i]
580+ if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
581+ continue
582+ }
583+ if !w.Fullscreen || !covers(&s, w) || full != nil {
584+ return
585+ }
586+ full = w
587+ }
588+ if full == nil {
589+ return
590+ }
591+ switch surfaces[full.ID].ContentType {
592+ case ports.ContentPhoto:
593+ o.contentWanted = o.contentValues[2]
594+ case ports.ContentVideo:
595+ o.contentWanted = o.contentValues[3]
596+ case ports.ContentGame:
597+ o.contentWanted = o.contentValues[4]
598+ }
599+}
600+
549 // commitFrame flips fb in one commit with the cursor and VRR. An async601 // commitFrame flips fb in one commit with the cursor and VRR. An async
550 // commit carries only the primary plane (kernel rule): a frame that must602 // commit carries only the primary plane (kernel rule): a frame that must
551 // also move the cursor or change VRR flips at vblank. fence is the603 // also move the cursor or change VRR flips at vblank. fence is the
552 // frame's GPU fence (nil: none); the caller keeps and closes it.604 // frame's GPU fence (nil: none); the caller keeps and closes it.
553 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {605 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {
554- return o.commitWith(fb, fence, async, vrr, f, overlayWin{})606+ return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()))
555 }607 }
556 608
557 // commitWith is commitFrame with ov on the overlay plane (zero: off).609 // commitWith is commitFrame with ov on the overlay plane (zero: off).
558 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {610 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {
611+ return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()))
612+}
613+
614+func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {
559 cur := cursorState{}615 cur := cursorState{}
560 if o.cursor != nil {616 if o.cursor != nil {
561 cur = o.cursor.desired()617 cur = o.cursor.desired()
562 }618 }
563- if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn) {619+ if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) {
564 async = false620 async = false
565 }621 }
566 req := &atomicReq{}622 req := &atomicReq{}
@@ -569,7 +625,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
569 flags |= flipAsyncFlag625 flags |= flipAsyncFlag
570 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))626 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))
571 } else {627 } else {
572- o.primaryProps(req, fb)628+ o.primaryRectProps(req, fb, rect)
573 req.set(o.crtc, o.vrrProp, boolValue(vrr))629 req.set(o.crtc, o.vrrProp, boolValue(vrr))
574 if o.cursor != nil {630 if o.cursor != nil {
575 o.cursor.props(req, o.crtc, cur)631 o.cursor.props(req, o.crtc, cur)
@@ -578,19 +634,25 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
578 o.overlayProps(req, ov)634 o.overlayProps(req, ov)
579 }635 }
580 }636 }
637+ o.contentProps(req)
581 if fence != nil {638 if fence != nil {
582 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))639 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
583 }640 }
584 // The kernel takes its own reference on the fence.641 // The kernel takes its own reference on the fence.
585 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {642 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
586 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {643 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
587- return o.commitWith(fb, fence, false, vrr, f, ov)644+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
645+ }
646+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.contentRefused(fb, fence, vrr, cur, ov, rect) {
647+ o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
648+ o.contentProp = 0
649+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
588 }650 }
589 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {651 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
590 // Retry without turning VRR on: if that passes, the driver652 // Retry without turning VRR on: if that passes, the driver
591 // refuses VRR on this output. The overlay stays: the composed653 // refuses VRR on this output. The overlay stays: the composed
592 // image left its window out.654 // image left its window out.
593- if o.commitWith(fb, fence, false, false, f, ov) == nil {655+ if o.commitWithRect(fb, fence, false, false, f, ov, rect) == nil {
594 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")656 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
595 o.vrrProp = 0657 o.vrrProp = 0
596 return nil658 return nil
@@ -611,6 +673,8 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
611 if !async {673 if !async {
612 o.overlayOn = ov.buf674 o.overlayOn = ov.buf
613 }675 }
676+ o.contentValue = o.contentWanted
677+ o.planeRect = rect
614 o.lastFrame, o.cursorHeld = time.Now(), false678 o.lastFrame, o.cursorHeld = time.Now(), false
615 f.frame, f.async = true, async679 f.frame, f.async = true, async
616 f.fences = dupFences(fence, ov.acquire)680 f.fences = dupFences(fence, ov.acquire)
@@ -619,6 +683,23 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
619 return nil683 return nil
620 }684 }
621 685
686+// contentRefused tests the same frame without the connector hint before disabling it.
687+func (o *Output) contentRefused(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect) bool {
688+ req := &atomicReq{}
689+ o.primaryRectProps(req, fb, rect)
690+ req.set(o.crtc, o.vrrProp, boolValue(vrr))
691+ if o.cursor != nil {
692+ o.cursor.props(req, o.crtc, cur)
693+ }
694+ if ov.buf != 0 || o.overlayOn != 0 {
695+ o.overlayProps(req, ov)
696+ }
697+ if fence != nil {
698+ req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
699+ }
700+ return o.k.commit(req, atomicTestOnly, 0) == nil
701+}
702+
622 // cursorRefused reports, after a commit carrying the cursor failed with703 // cursorRefused reports, after a commit carrying the cursor failed with
623 // EINVAL, whether the cursor plane was the cause; then the cursor is off704 // EINVAL, whether the cursor plane was the cause; then the cursor is off
624 // and the commit can be retried without it.705 // and the commit can be retried without it.
@@ -647,6 +728,7 @@ func (o *Output) commitState(vrr bool) error {
647 if vrr != o.vrrOn {728 if vrr != o.vrrOn {
648 req.set(o.crtc, o.vrrProp, boolValue(vrr))729 req.set(o.crtc, o.vrrProp, boolValue(vrr))
649 }730 }
731+ o.contentProps(req)
650 if len(req.objs) == 0 {732 if len(req.objs) == 0 {
651 return nil733 return nil
652 }734 }
@@ -658,6 +740,20 @@ func (o *Output) commitState(vrr bool) error {
658 if cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {740 if cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
659 return o.commitState(vrr)741 return o.commitState(vrr)
660 }742 }
743+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) {
744+ without := &atomicReq{}
745+ if o.cursor != nil && cur != o.cursor.applied {
746+ o.cursor.props(without, o.crtc, cur)
747+ }
748+ if vrr != o.vrrOn {
749+ without.set(o.crtc, o.vrrProp, boolValue(vrr))
750+ }
751+ if len(without.objs) == 0 || o.k.commit(without, atomicTestOnly, 0) == nil {
752+ o.contentProp = 0
753+ o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
754+ return o.commitState(vrr)
755+ }
756+ }
661 return err757 return err
662 }758 }
663 if o.cursor != nil {759 if o.cursor != nil {
@@ -667,6 +763,7 @@ func (o *Output) commitState(vrr bool) error {
667 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")763 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
668 }764 }
669 o.vrrOn = vrr765 o.vrrOn = vrr
766+ o.contentValue = o.contentWanted
670 o.frame.begin(pendingFrame{}, time.Now())767 o.frame.begin(pendingFrame{}, time.Now())
671 return nil768 return nil
672 }769 }
@@ -693,6 +790,10 @@ func boolValue(b bool) uint64 {
693 // commitScanout flips a client buffer. It reports false when KMS refused790 // commitScanout flips a client buffer. It reports false when KMS refused
694 // the buffer: the frame must be composed.791 // the buffer: the frame must be composed.
695 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {792 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
793+ return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()))
794+}
795+
796+func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect) (bool, error) {
696 cfb := o.clientFBs[c.DMABuf.ID]797 cfb := o.clientFBs[c.DMABuf.ID]
697 // Drivers refuse async flips that change the format or modifier: the798 // Drivers refuse async flips that change the format or modifier: the
698 // flip from the composed image into scanout waits for vblank.799 // flip from the composed image into scanout waits for vblank.
@@ -706,13 +807,13 @@ func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame
706 if fence != nil {807 if fence != nil {
707 defer fence.Close()808 defer fence.Close()
708 }809 }
709- err := o.commitFrame(fb, fence, async, vrr, f)810+ err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect)
710 if err != nil && async && errors.Is(err, unix.EINVAL) {811 if err != nil && async && errors.Is(err, unix.EINVAL) {
711 // Refused (e.g. not a fast update): this buffer flips at vblank812 // Refused (e.g. not a fast update): this buffer flips at vblank
712 // from now on.813 // from now on.
713 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")814 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
714 cfb.noAsync = true815 cfb.noAsync = true
715- err = o.commitFrame(fb, fence, false, vrr, f)816+ err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect)
716 }817 }
717 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {818 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
718 // KMS refuses this buffer on the plane: compose it instead.819 // KMS refuses this buffer on the plane: compose it instead.
M internal/adapters/drm/output_test.go +1 -1
@@ -67,7 +67,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
6767 cur.fbs = [2]uint32{90, 91}
6868 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
6969 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
70- primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
70+ primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100)}
7171 return o, k, &commits, commitMu
7272 }
7373
@@ -67,7 +67,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
67 cur.fbs = [2]uint32{90, 91}67 cur.fbs = [2]uint32{90, 91}
68 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),68 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
69 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,69 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
70- primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}70+ primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100)}
71 return o, k, &commits, commitMu71 return o, k, &commits, commitMu
72 }72 }
73 73
M internal/adapters/drm/props.go +37 -0
@@ -61,6 +61,43 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
6161 return out, nil
6262 }
6363
64+// readContentTypeProp records the connector's content type enum values.
65+func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {
66+ id := uint32(props["content type"][0])
67+ var values [5]uint64
68+ if id == 0 {
69+ return 0, values
70+ }
71+ p := getProp{propID: id}
72+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil || p.countEn == 0 {
73+ return 0, values
74+ }
75+ enums := make([]propEnum, p.countEn)
76+ v := make([]uint64, max(p.countValues, 1))
77+ p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
78+ p.values = uint64(uintptr(unsafe.Pointer(&v[0])))
79+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil {
80+ return 0, values
81+ }
82+ for _, e := range enums[:min(int(p.countEn), len(enums))] {
83+ switch unix.ByteSliceToString(e.name[:]) {
84+ case "No Data":
85+ values[0] = e.value
86+ case "Graphics":
87+ values[1] = e.value
88+ case "Photo":
89+ values[2] = e.value
90+ case "Cinema":
91+ values[3] = e.value
92+ case "Game":
93+ values[4] = e.value
94+ }
95+ }
96+ runtime.KeepAlive(enums)
97+ runtime.KeepAlive(v)
98+ return id, values
99+}
100+
64101 // connectorHDRProps retains connector property IDs, enum values and the
65102 // original max bpc for SDR modesets and restoration.
66103 type connectorHDRProps struct {
@@ -61,6 +61,43 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
61 return out, nil61 return out, nil
62 }62 }
63 63
64+// readContentTypeProp records the connector's content type enum values.
65+func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {
66+ id := uint32(props["content type"][0])
67+ var values [5]uint64
68+ if id == 0 {
69+ return 0, values
70+ }
71+ p := getProp{propID: id}
72+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil || p.countEn == 0 {
73+ return 0, values
74+ }
75+ enums := make([]propEnum, p.countEn)
76+ v := make([]uint64, max(p.countValues, 1))
77+ p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
78+ p.values = uint64(uintptr(unsafe.Pointer(&v[0])))
79+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil {
80+ return 0, values
81+ }
82+ for _, e := range enums[:min(int(p.countEn), len(enums))] {
83+ switch unix.ByteSliceToString(e.name[:]) {
84+ case "No Data":
85+ values[0] = e.value
86+ case "Graphics":
87+ values[1] = e.value
88+ case "Photo":
89+ values[2] = e.value
90+ case "Cinema":
91+ values[3] = e.value
92+ case "Game":
93+ values[4] = e.value
94+ }
95+ }
96+ runtime.KeepAlive(enums)
97+ runtime.KeepAlive(v)
98+ return id, values
99+}
100+
64 // connectorHDRProps retains connector property IDs, enum values and the101 // connectorHDRProps retains connector property IDs, enum values and the
65 // original max bpc for SDR modesets and restoration.102 // original max bpc for SDR modesets and restoration.
66 type connectorHDRProps struct {103 type connectorHDRProps struct {
M internal/adapters/drm/scanout.go +63 -11
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "errors"
5+ "math"
56 "os"
67 "slices"
78 "time"
@@ -12,12 +13,11 @@ import (
1213 )
1314
1415 // Direct scanout (ADR 006): when one fullscreen window covers the output
15-// with a GPU buffer of the output's size, the output flips to that buffer
16+// with a GPU buffer whose source can fill the plane, the output flips to that buffer
1617 // instead of compositing it. The buffer becomes a KMS framebuffer once
1718 // (PRIME import + ADDFB2) and is cached by DMABuf ID while the client
1819 // keeps using it. KMS waits on the buffer's implicit fences before showing
19-// it. Anything else on screen, a scale, or a buffer KMS refuses falls back
20-// to composition.
20+// it. Anything else on screen or a buffer KMS refuses falls back to composition.
2121
2222 const (
2323 ioctlGemClose = 0x40086409
@@ -43,6 +43,27 @@ type fbCmd2 struct {
4343 modifiers [4]uint64
4444 }
4545
46+// planeRect contains source coordinates in 16.16 and destination pixels.
47+type planeRect struct {
48+ src [4]uint64
49+ crtc [4]uint64
50+}
51+
52+func fullPlaneRect(w, h int) planeRect {
53+ return planeRect{src: [4]uint64{0, 0, uint64(w) << 16, uint64(h) << 16}, crtc: [4]uint64{0, 0, uint64(w), uint64(h)}}
54+}
55+
56+func scanoutRect(c ports.SurfaceContent, w, h int) planeRect {
57+ r := fullPlaneRect(w, h)
58+ r.src = [4]uint64{0, 0, uint64(c.Width) << 16, uint64(c.Height) << 16}
59+ if c.Source[2] != 0 {
60+ for i, value := range c.Source {
61+ r.src[i] = uint64(float64(value) * 65536)
62+ }
63+ }
64+ return r
65+}
66+
4667 // covers reports whether win fills the output of scene s.
4768 func covers(s *ports.Scene, win *ports.SceneWindow) bool {
4869 return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
@@ -104,19 +125,28 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
104125 case c.Transform != 0:
105126 // Planes show buffers as they are: rotation is composed.
106127 return c, "buffer_transform"
107- case c.Width != w || c.Height != h:
108- return c, "size_mismatch"
109- case cropped(c):
110- // A viewport crop: the plane would show the whole buffer.
111- return c, "source_crop"
112128 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
113- return c, "logical_mismatch"
129+ return c, "letterbox"
130+ case c.Width <= 0 || c.Height <= 0 || c.Source[2] < 0 || c.Source[3] < 0 || !finiteSource(c.Source):
131+ return c, "source_crop"
114132 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
115133 return c, "geometry_crop"
116134 }
135+ if c.Source[2] != 0 && (c.Source[0] < 0 || c.Source[1] < 0 || c.Source[2] <= 0 || c.Source[3] <= 0 || c.Source[0]+c.Source[2] > float32(c.Width) || c.Source[1]+c.Source[3] > float32(c.Height)) {
136+ return c, "source_crop"
137+ }
117138 return c, ""
118139 }
119140
141+func finiteSource(src [4]float32) bool {
142+ for _, n := range src {
143+ if math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) {
144+ return false
145+ }
146+ }
147+ return true
148+}
149+
120150 // cropped reports whether a viewport source shows less than the buffer.
121151 func cropped(c ports.SurfaceContent) bool {
122152 return c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}
@@ -173,6 +203,25 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
173203 }
174204 if reason == "" {
175205 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
206+ if reason == "" {
207+ rect := scanoutRect(c, o.Width(), o.Height())
208+ cfb := o.clientFBs[c.DMABuf.ID]
209+ if cfb.scaleRefused && cfb.scaleFailed == rect {
210+ reason = "scale_refused"
211+ } else if rect != fullPlaneRect(o.Width(), o.Height()) && (!cfb.scaleTestedOK || cfb.scaleTested != rect) {
212+ req := &atomicReq{}
213+ o.primaryRectProps(req, fb, rect)
214+ if err := o.k.commit(req, atomicTestOnly, 0); err != nil {
215+ cfb.scaleFailed = rect
216+ cfb.scaleRefused = true
217+ reason = "scale_refused"
218+ o.log.Info().Str("component", "render").Err(err).Str("reason", reason).Str("connector", o.conn.name).Msg("scanout scaling refused")
219+ } else {
220+ cfb.scaleTested = rect
221+ cfb.scaleTestedOK = true
222+ }
223+ }
224+ }
176225 }
177226 o.setScanoutReason(reason)
178227 if reason != "" {
@@ -225,6 +274,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
225274 type frameDecision struct {
226275 fb uint32
227276 content ports.SurfaceContent
277+ rect planeRect
228278 overlay overlayWin
229279 composed ports.Scene
230280 }
@@ -249,7 +299,7 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su
249299 }
250300 fb, c := o.scanoutFrame(s, surfaces)
251301 if fb != 0 {
252- return frameDecision{fb: fb, content: c}
302+ return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height())}
253303 }
254304 return o.composeFrame(s, surfaces)
255305 }
@@ -277,7 +327,9 @@ type clientFB struct {
277327 // failed is the reason KMS refused the buffer; it is not retried.
278328 failed string
279329 // noAsync: KMS refused an async flip to it; it flips at vblank.
280- noAsync bool
330+ noAsync bool
331+ scaleTested, scaleFailed planeRect
332+ scaleRefused, scaleTestedOK bool
281333 // overlayFailed is why the overlay plane refused it; kept apart from
282334 // failed, so a refusal on one plane does not bar the other.
283335 overlayFailed string
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "math"
5 "os"6 "os"
6 "slices"7 "slices"
7 "time"8 "time"
@@ -12,12 +13,11 @@ import (
12 )13 )
13 14
14 // Direct scanout (ADR 006): when one fullscreen window covers the output15 // Direct scanout (ADR 006): when one fullscreen window covers the output
15-// with a GPU buffer of the output's size, the output flips to that buffer16+// with a GPU buffer whose source can fill the plane, the output flips to that buffer
16 // instead of compositing it. The buffer becomes a KMS framebuffer once17 // instead of compositing it. The buffer becomes a KMS framebuffer once
17 // (PRIME import + ADDFB2) and is cached by DMABuf ID while the client18 // (PRIME import + ADDFB2) and is cached by DMABuf ID while the client
18 // keeps using it. KMS waits on the buffer's implicit fences before showing19 // keeps using it. KMS waits on the buffer's implicit fences before showing
19-// it. Anything else on screen, a scale, or a buffer KMS refuses falls back20+// it. Anything else on screen or a buffer KMS refuses falls back to composition.
20-// to composition.
21 21
22 const (22 const (
23 ioctlGemClose = 0x4008640923 ioctlGemClose = 0x40086409
@@ -43,6 +43,27 @@ type fbCmd2 struct {
43 modifiers [4]uint6443 modifiers [4]uint64
44 }44 }
45 45
46+// planeRect contains source coordinates in 16.16 and destination pixels.
47+type planeRect struct {
48+ src [4]uint64
49+ crtc [4]uint64
50+}
51+
52+func fullPlaneRect(w, h int) planeRect {
53+ return planeRect{src: [4]uint64{0, 0, uint64(w) << 16, uint64(h) << 16}, crtc: [4]uint64{0, 0, uint64(w), uint64(h)}}
54+}
55+
56+func scanoutRect(c ports.SurfaceContent, w, h int) planeRect {
57+ r := fullPlaneRect(w, h)
58+ r.src = [4]uint64{0, 0, uint64(c.Width) << 16, uint64(c.Height) << 16}
59+ if c.Source[2] != 0 {
60+ for i, value := range c.Source {
61+ r.src[i] = uint64(float64(value) * 65536)
62+ }
63+ }
64+ return r
65+}
66+
46 // covers reports whether win fills the output of scene s.67 // covers reports whether win fills the output of scene s.
47 func covers(s *ports.Scene, win *ports.SceneWindow) bool {68 func covers(s *ports.Scene, win *ports.SceneWindow) bool {
48 return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight69 return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
@@ -104,19 +125,28 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
104 case c.Transform != 0:125 case c.Transform != 0:
105 // Planes show buffers as they are: rotation is composed.126 // Planes show buffers as they are: rotation is composed.
106 return c, "buffer_transform"127 return c, "buffer_transform"
107- case c.Width != w || c.Height != h:
108- return c, "size_mismatch"
109- case cropped(c):
110- // A viewport crop: the plane would show the whole buffer.
111- return c, "source_crop"
112 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:128 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
113- return c, "logical_mismatch"129+ return c, "letterbox"
130+ case c.Width <= 0 || c.Height <= 0 || c.Source[2] < 0 || c.Source[3] < 0 || !finiteSource(c.Source):
131+ return c, "source_crop"
114 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):132 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
115 return c, "geometry_crop"133 return c, "geometry_crop"
116 }134 }
135+ if c.Source[2] != 0 && (c.Source[0] < 0 || c.Source[1] < 0 || c.Source[2] <= 0 || c.Source[3] <= 0 || c.Source[0]+c.Source[2] > float32(c.Width) || c.Source[1]+c.Source[3] > float32(c.Height)) {
136+ return c, "source_crop"
137+ }
117 return c, ""138 return c, ""
118 }139 }
119 140
141+func finiteSource(src [4]float32) bool {
142+ for _, n := range src {
143+ if math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) {
144+ return false
145+ }
146+ }
147+ return true
148+}
149+
120 // cropped reports whether a viewport source shows less than the buffer.150 // cropped reports whether a viewport source shows less than the buffer.
121 func cropped(c ports.SurfaceContent) bool {151 func cropped(c ports.SurfaceContent) bool {
122 return c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}152 return c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}
@@ -173,6 +203,25 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
173 }203 }
174 if reason == "" {204 if reason == "" {
175 fb, reason = o.scanoutFB(c.DMABuf, time.Now())205 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
206+ if reason == "" {
207+ rect := scanoutRect(c, o.Width(), o.Height())
208+ cfb := o.clientFBs[c.DMABuf.ID]
209+ if cfb.scaleRefused && cfb.scaleFailed == rect {
210+ reason = "scale_refused"
211+ } else if rect != fullPlaneRect(o.Width(), o.Height()) && (!cfb.scaleTestedOK || cfb.scaleTested != rect) {
212+ req := &atomicReq{}
213+ o.primaryRectProps(req, fb, rect)
214+ if err := o.k.commit(req, atomicTestOnly, 0); err != nil {
215+ cfb.scaleFailed = rect
216+ cfb.scaleRefused = true
217+ reason = "scale_refused"
218+ o.log.Info().Str("component", "render").Err(err).Str("reason", reason).Str("connector", o.conn.name).Msg("scanout scaling refused")
219+ } else {
220+ cfb.scaleTested = rect
221+ cfb.scaleTestedOK = true
222+ }
223+ }
224+ }
176 }225 }
177 o.setScanoutReason(reason)226 o.setScanoutReason(reason)
178 if reason != "" {227 if reason != "" {
@@ -225,6 +274,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
225 type frameDecision struct {274 type frameDecision struct {
226 fb uint32275 fb uint32
227 content ports.SurfaceContent276 content ports.SurfaceContent
277+ rect planeRect
228 overlay overlayWin278 overlay overlayWin
229 composed ports.Scene279 composed ports.Scene
230 }280 }
@@ -249,7 +299,7 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su
249 }299 }
250 fb, c := o.scanoutFrame(s, surfaces)300 fb, c := o.scanoutFrame(s, surfaces)
251 if fb != 0 {301 if fb != 0 {
252- return frameDecision{fb: fb, content: c}302+ return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height())}
253 }303 }
254 return o.composeFrame(s, surfaces)304 return o.composeFrame(s, surfaces)
255 }305 }
@@ -277,7 +327,9 @@ type clientFB struct {
277 // failed is the reason KMS refused the buffer; it is not retried.327 // failed is the reason KMS refused the buffer; it is not retried.
278 failed string328 failed string
279 // noAsync: KMS refused an async flip to it; it flips at vblank.329 // noAsync: KMS refused an async flip to it; it flips at vblank.
280- noAsync bool330+ noAsync bool
331+ scaleTested, scaleFailed planeRect
332+ scaleRefused, scaleTestedOK bool
281 // overlayFailed is why the overlay plane refused it; kept apart from333 // overlayFailed is why the overlay plane refused it; kept apart from
282 // failed, so a refusal on one plane does not bar the other.334 // failed, so a refusal on one plane does not bar the other.
283 overlayFailed string335 overlayFailed string
M internal/adapters/drm/scanout_test.go +6 -6
@@ -43,20 +43,20 @@ func TestScanoutCandidate(t *testing.T) {
4343 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
4444 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
4545 {"buffer transform", nil, func(c *ports.SurfaceContent) { c.Transform = 2 }, "buffer_transform"},
46- {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
46+ {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, ""},
4747 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
48- {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
48+ {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "letterbox"},
4949 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
5050 {"wine client subsurface", nil, wineTree(nil), ""},
5151 {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},
5252 {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},
5353 {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
5454 {"client subsurface below root", nil, wineTree(func(ch *ports.Subsurface) { ch.Below = true }), "subsurface_below"},
55- {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), "source_crop"},
55+ {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), ""},
5656 {"client subsurface full source", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 200, 100} }), ""},
57- {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, "source_crop"},
58- {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},
59- {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},
57+ {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, ""},
58+ {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), ""},
59+ {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "letterbox"},
6060 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
6161 wineTree(nil)(c)
6262 c.Children = append(c.Children, c.Children[0])
@@ -43,20 +43,20 @@ func TestScanoutCandidate(t *testing.T) {
43 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},43 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
44 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},44 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
45 {"buffer transform", nil, func(c *ports.SurfaceContent) { c.Transform = 2 }, "buffer_transform"},45 {"buffer transform", nil, func(c *ports.SurfaceContent) { c.Transform = 2 }, "buffer_transform"},
46- {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},46+ {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, ""},
47 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},47 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
48- {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},48+ {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "letterbox"},
49 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},49 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
50 {"wine client subsurface", nil, wineTree(nil), ""},50 {"wine client subsurface", nil, wineTree(nil), ""},
51 {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},51 {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},
52 {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},52 {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},
53 {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},53 {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
54 {"client subsurface below root", nil, wineTree(func(ch *ports.Subsurface) { ch.Below = true }), "subsurface_below"},54 {"client subsurface below root", nil, wineTree(func(ch *ports.Subsurface) { ch.Below = true }), "subsurface_below"},
55- {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), "source_crop"},55+ {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), ""},
56 {"client subsurface full source", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 200, 100} }), ""},56 {"client subsurface full source", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 200, 100} }), ""},
57- {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, "source_crop"},57+ {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, ""},
58- {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},58+ {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), ""},
59- {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},59+ {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "letterbox"},
60 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {60 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
61 wineTree(nil)(c)61 wineTree(nil)(c)
62 c.Children = append(c.Children, c.Children[0])62 c.Children = append(c.Children, c.Children[0])
A internal/adapters/sched/sched.go +28 -0
@@ -0,0 +1,28 @@
1+// Package sched configures latency-sensitive compositor threads.
2+package sched
3+
4+import (
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/zerowrap"
9+ "golang.org/x/sys/unix"
10+)
11+
12+var unavailable sync.Once
13+
14+// Realtime sets SCHED_RR at its minimum priority on the calling locked OS thread.
15+// Children cannot inherit this scheduling policy.
16+func Realtime(log zerowrap.Logger) error {
17+ err := unix.SchedSetAttr(0, &unix.SchedAttr{
18+ Size: unix.SizeofSchedAttr, Policy: unix.SCHED_RR,
19+ Flags: unix.SCHED_FLAG_RESET_ON_FORK, Priority: 1,
20+ }, 0)
21+ if errors.Is(err, unix.EPERM) {
22+ unavailable.Do(func() {
23+ log.Info().Str("component", "sched").Msg("realtime scheduling unavailable, grant CAP_SYS_NICE")
24+ })
25+ return nil
26+ }
27+ return err
28+}
@@ -0,0 +1,28 @@
1+// Package sched configures latency-sensitive compositor threads.
2+package sched
3+
4+import (
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/zerowrap"
9+ "golang.org/x/sys/unix"
10+)
11+
12+var unavailable sync.Once
13+
14+// Realtime sets SCHED_RR at its minimum priority on the calling locked OS thread.
15+// Children cannot inherit this scheduling policy.
16+func Realtime(log zerowrap.Logger) error {
17+ err := unix.SchedSetAttr(0, &unix.SchedAttr{
18+ Size: unix.SizeofSchedAttr, Policy: unix.SCHED_RR,
19+ Flags: unix.SCHED_FLAG_RESET_ON_FORK, Priority: 1,
20+ }, 0)
21+ if errors.Is(err, unix.EPERM) {
22+ unavailable.Do(func() {
23+ log.Info().Str("component", "sched").Msg("realtime scheduling unavailable, grant CAP_SYS_NICE")
24+ })
25+ return nil
26+ }
27+ return err
28+}
A internal/adapters/sched/sched_test.go +21 -0
@@ -0,0 +1,21 @@
1+package sched
2+
3+import (
4+ "context"
5+ "runtime"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+)
10+
11+func TestRealtime(t *testing.T) {
12+ result := make(chan error, 1)
13+ go func() {
14+ runtime.LockOSThread()
15+ defer runtime.UnlockOSThread()
16+ result <- Realtime(logging.For(context.Background(), "sched"))
17+ }()
18+ if err := <-result; err != nil {
19+ t.Fatal(err)
20+ }
21+}
@@ -0,0 +1,21 @@
1+package sched
2+
3+import (
4+ "context"
5+ "runtime"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+)
10+
11+func TestRealtime(t *testing.T) {
12+ result := make(chan error, 1)
13+ go func() {
14+ runtime.LockOSThread()
15+ defer runtime.UnlockOSThread()
16+ result <- Realtime(logging.For(context.Background(), "sched"))
17+ }()
18+ if err := <-result; err != nil {
19+ t.Fatal(err)
20+ }
21+}
M internal/adapters/vulkan/dmabuf.go +5 -10
@@ -72,26 +72,21 @@ func vkFormat(f uint32) (vk.Format, bool, bool) {
7272 return 0, false, false
7373 }
7474
75-// hasExtensions reports whether the device offers every dmabuf extension.
76-func (r *Renderer) hasExtensions(physical vk.PhysicalDevice) bool {
75+// extensions lists the device extensions supported by the selected GPU.
76+func (r *Renderer) extensions(physical vk.PhysicalDevice) map[string]bool {
7777 var n uint32
7878 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, nil) != vk.Success || n == 0 {
79- return false
79+ return nil
8080 }
8181 props := make([]vk.ExtensionProperties, n)
8282 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, &props[0]) != vk.Success {
83- return false
83+ return nil
8484 }
8585 have := map[string]bool{}
8686 for _, p := range props[:n] {
8787 have[cstring(p.ExtensionName[:])] = true
8888 }
89- for _, e := range deviceExtensions {
90- if !have[e] {
91- return false
92- }
93- }
94- return true
89+ return have
9590 }
9691
9792 func cstring(b []byte) string {
@@ -72,26 +72,21 @@ func vkFormat(f uint32) (vk.Format, bool, bool) {
72 return 0, false, false72 return 0, false, false
73 }73 }
74 74
75-// hasExtensions reports whether the device offers every dmabuf extension.75+// extensions lists the device extensions supported by the selected GPU.
76-func (r *Renderer) hasExtensions(physical vk.PhysicalDevice) bool {76+func (r *Renderer) extensions(physical vk.PhysicalDevice) map[string]bool {
77 var n uint3277 var n uint32
78 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, nil) != vk.Success || n == 0 {78 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, nil) != vk.Success || n == 0 {
79- return false79+ return nil
80 }80 }
81 props := make([]vk.ExtensionProperties, n)81 props := make([]vk.ExtensionProperties, n)
82 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, &props[0]) != vk.Success {82 if r.id.EnumerateDeviceExtensionProperties(physical, nil, &n, &props[0]) != vk.Success {
83- return false83+ return nil
84 }84 }
85 have := map[string]bool{}85 have := map[string]bool{}
86 for _, p := range props[:n] {86 for _, p := range props[:n] {
87 have[cstring(p.ExtensionName[:])] = true87 have[cstring(p.ExtensionName[:])] = true
88 }88 }
89- for _, e := range deviceExtensions {89+ return have
90- if !have[e] {
91- return false
92- }
93- }
94- return true
95 }90 }
96 91
97 func cstring(b []byte) string {92 func cstring(b []byte) string {
A internal/adapters/vulkan/priority_test.go +17 -0
@@ -0,0 +1,17 @@
1+package vulkan
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ vk "github.com/bnema/purego-vulkan/vulkan"
8+)
9+
10+func TestPriorityAttempts(t *testing.T) {
11+ if got := priorityAttempts(true); !slices.Equal(got, []vk.QueueGlobalPriority{vk.QueueGlobalPriorityRealtime, vk.QueueGlobalPriorityHigh, 0}) {
12+ t.Fatal(got)
13+ }
14+ if got := priorityAttempts(false); !slices.Equal(got, []vk.QueueGlobalPriority{0}) {
15+ t.Fatal(got)
16+ }
17+}
@@ -0,0 +1,17 @@
1+package vulkan
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ vk "github.com/bnema/purego-vulkan/vulkan"
8+)
9+
10+func TestPriorityAttempts(t *testing.T) {
11+ if got := priorityAttempts(true); !slices.Equal(got, []vk.QueueGlobalPriority{vk.QueueGlobalPriorityRealtime, vk.QueueGlobalPriorityHigh, 0}) {
12+ t.Fatal(got)
13+ }
14+ if got := priorityAttempts(false); !slices.Equal(got, []vk.QueueGlobalPriority{0}) {
15+ t.Fatal(got)
16+ }
17+}
M internal/adapters/vulkan/renderer.go +69 -4
@@ -17,6 +17,9 @@ import (
1717 // Renderer is owned by one goroutine; its methods must not be called concurrently.
1818 type Renderer struct {
1919 width, height int
20+ // queuePriority is the global priority the queue got: realtime, high
21+ // or default.
22+ queuePriority string
2023 instance vk.Instance
2124 id *vk.InstanceDispatch
2225 device vk.Device
@@ -89,6 +92,14 @@ type Renderer struct {
8992 frameStages []vk.PipelineStageFlags
9093 }
9194
95+// priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
96+func priorityAttempts(supported bool) []vk.QueueGlobalPriority {
97+ if supported {
98+ return []vk.QueueGlobalPriority{vk.QueueGlobalPriorityRealtime, vk.QueueGlobalPriorityHigh, 0}
99+ }
100+ return []vk.QueueGlobalPriority{0}
101+}
102+
92103 func checked(name string, result vk.Result) error {
93104 if err := vk.Check(result); err != nil {
94105 return fmt.Errorf("%s: %w", name, err)
@@ -185,7 +196,14 @@ func New(width, height int) (r *Renderer, err error) {
185196 // dmabuf import needs every extension; without them clients use wl_shm.
186197 var extNames [][]byte
187198 var extPtrs []*byte
188- if r.hasExtensions(physical) {
199+ have := r.extensions(physical)
200+ dmabufSupported := true
201+ for _, e := range deviceExtensions {
202+ if !have[e] {
203+ dmabufSupported = false
204+ }
205+ }
206+ if dmabufSupported {
189207 for _, e := range deviceExtensions {
190208 extNames = append(extNames, append([]byte(e), 0))
191209 extPtrs = append(extPtrs, &extNames[len(extNames)-1][0])
@@ -193,9 +211,52 @@ func New(width, height int) (r *Renderer, err error) {
193211 di.EnabledExtensionCount = uint32(len(extPtrs))
194212 di.PpEnabledExtensionNames = &extPtrs[0]
195213 }
196- if err = checked("vkCreateDevice", r.id.CreateDevice(physical, &di, nil, &r.device)); err != nil {
197- return
214+ globalExt := ""
215+ if have[vk.KHRGlobalPriorityExtensionName] {
216+ globalExt = vk.KHRGlobalPriorityExtensionName
217+ } else if have[vk.EXTGlobalPriorityExtensionName] {
218+ globalExt = vk.EXTGlobalPriorityExtensionName
219+ }
220+ var global vk.DeviceQueueGlobalPriorityCreateInfo
221+ chosen := "default"
222+ for _, attempt := range priorityAttempts(globalExt != "") {
223+ qi.Next = nil
224+ count := len(extPtrs)
225+ if attempt != 0 {
226+ global = vk.DeviceQueueGlobalPriorityCreateInfo{SType: vk.StructureTypeDeviceQueueGlobalPriorityCreateInfo, GlobalPriority: attempt}
227+ qi.Next = unsafe.Pointer(&global)
228+ extNames = append(extNames, append([]byte(globalExt), 0))
229+ extPtrs = append(extPtrs, &extNames[len(extNames)-1][0])
230+ chosen = "realtime"
231+ if attempt == vk.QueueGlobalPriorityHigh {
232+ chosen = "high"
233+ }
234+ } else {
235+ chosen = "default"
236+ }
237+ di.EnabledExtensionCount = uint32(len(extPtrs))
238+ if len(extPtrs) > 0 {
239+ di.PpEnabledExtensionNames = &extPtrs[0]
240+ } else {
241+ di.PpEnabledExtensionNames = nil
242+ }
243+ result := r.id.CreateDevice(physical, &di, nil, &r.device)
244+ runtime.KeepAlive(global)
245+ runtime.KeepAlive(qi)
246+ runtime.KeepAlive(extNames)
247+ runtime.KeepAlive(extPtrs)
248+ extPtrs = extPtrs[:count]
249+ extNames = extNames[:count]
250+ if result == vk.Success {
251+ err = nil
252+ break
253+ }
254+ err = checked("vkCreateDevice", result)
255+ if attempt == 0 {
256+ return
257+ }
198258 }
259+ r.queuePriority = chosen
199260 r.dd, err = vk.LoadDeviceDispatch(r.id, r.device)
200261 if err != nil {
201262 err = fmt.Errorf("LoadDeviceDispatch: %w", err)
@@ -203,7 +264,7 @@ func New(width, height int) (r *Renderer, err error) {
203264 }
204265 runtime.KeepAlive(extNames)
205266 runtime.KeepAlive(extPtrs)
206- if len(extPtrs) > 0 {
267+ if dmabufSupported {
207268 // Clients need the render node to allocate on the right GPU.
208269 if sup := r.probeDMABuf(physical); sup.Device != 0 {
209270 r.dmabuf = sup
@@ -326,6 +387,10 @@ func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (ui
326387 // It is diagnostic only and must be read by the renderer's owner goroutine.
327388 func (r *Renderer) CopiedBytes() int { return r.copied }
328389
390+// QueuePriority is the global priority the queue got: realtime, high or
391+// default.
392+func (r *Renderer) QueuePriority() string { return r.queuePriority }
393+
329394 func (r *Renderer) Pixels() *image.RGBA {
330395 out := image.NewRGBA(image.Rect(0, 0, r.width, r.height))
331396 if r.mapped == nil || r.readback() != nil {
@@ -17,6 +17,9 @@ import (
17 // Renderer is owned by one goroutine; its methods must not be called concurrently.17 // Renderer is owned by one goroutine; its methods must not be called concurrently.
18 type Renderer struct {18 type Renderer struct {
19 width, height int19 width, height int
20+ // queuePriority is the global priority the queue got: realtime, high
21+ // or default.
22+ queuePriority string
20 instance vk.Instance23 instance vk.Instance
21 id *vk.InstanceDispatch24 id *vk.InstanceDispatch
22 device vk.Device25 device vk.Device
@@ -89,6 +92,14 @@ type Renderer struct {
89 frameStages []vk.PipelineStageFlags92 frameStages []vk.PipelineStageFlags
90 }93 }
91 94
95+// priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
96+func priorityAttempts(supported bool) []vk.QueueGlobalPriority {
97+ if supported {
98+ return []vk.QueueGlobalPriority{vk.QueueGlobalPriorityRealtime, vk.QueueGlobalPriorityHigh, 0}
99+ }
100+ return []vk.QueueGlobalPriority{0}
101+}
102+
92 func checked(name string, result vk.Result) error {103 func checked(name string, result vk.Result) error {
93 if err := vk.Check(result); err != nil {104 if err := vk.Check(result); err != nil {
94 return fmt.Errorf("%s: %w", name, err)105 return fmt.Errorf("%s: %w", name, err)
@@ -185,7 +196,14 @@ func New(width, height int) (r *Renderer, err error) {
185 // dmabuf import needs every extension; without them clients use wl_shm.196 // dmabuf import needs every extension; without them clients use wl_shm.
186 var extNames [][]byte197 var extNames [][]byte
187 var extPtrs []*byte198 var extPtrs []*byte
188- if r.hasExtensions(physical) {199+ have := r.extensions(physical)
200+ dmabufSupported := true
201+ for _, e := range deviceExtensions {
202+ if !have[e] {
203+ dmabufSupported = false
204+ }
205+ }
206+ if dmabufSupported {
189 for _, e := range deviceExtensions {207 for _, e := range deviceExtensions {
190 extNames = append(extNames, append([]byte(e), 0))208 extNames = append(extNames, append([]byte(e), 0))
191 extPtrs = append(extPtrs, &extNames[len(extNames)-1][0])209 extPtrs = append(extPtrs, &extNames[len(extNames)-1][0])
@@ -193,9 +211,52 @@ func New(width, height int) (r *Renderer, err error) {
193 di.EnabledExtensionCount = uint32(len(extPtrs))211 di.EnabledExtensionCount = uint32(len(extPtrs))
194 di.PpEnabledExtensionNames = &extPtrs[0]212 di.PpEnabledExtensionNames = &extPtrs[0]
195 }213 }
196- if err = checked("vkCreateDevice", r.id.CreateDevice(physical, &di, nil, &r.device)); err != nil {214+ globalExt := ""
197- return215+ if have[vk.KHRGlobalPriorityExtensionName] {
216+ globalExt = vk.KHRGlobalPriorityExtensionName
217+ } else if have[vk.EXTGlobalPriorityExtensionName] {
218+ globalExt = vk.EXTGlobalPriorityExtensionName
219+ }
220+ var global vk.DeviceQueueGlobalPriorityCreateInfo
221+ chosen := "default"
222+ for _, attempt := range priorityAttempts(globalExt != "") {
223+ qi.Next = nil
224+ count := len(extPtrs)
225+ if attempt != 0 {
226+ global = vk.DeviceQueueGlobalPriorityCreateInfo{SType: vk.StructureTypeDeviceQueueGlobalPriorityCreateInfo, GlobalPriority: attempt}
227+ qi.Next = unsafe.Pointer(&global)
228+ extNames = append(extNames, append([]byte(globalExt), 0))
229+ extPtrs = append(extPtrs, &extNames[len(extNames)-1][0])
230+ chosen = "realtime"
231+ if attempt == vk.QueueGlobalPriorityHigh {
232+ chosen = "high"
233+ }
234+ } else {
235+ chosen = "default"
236+ }
237+ di.EnabledExtensionCount = uint32(len(extPtrs))
238+ if len(extPtrs) > 0 {
239+ di.PpEnabledExtensionNames = &extPtrs[0]
240+ } else {
241+ di.PpEnabledExtensionNames = nil
242+ }
243+ result := r.id.CreateDevice(physical, &di, nil, &r.device)
244+ runtime.KeepAlive(global)
245+ runtime.KeepAlive(qi)
246+ runtime.KeepAlive(extNames)
247+ runtime.KeepAlive(extPtrs)
248+ extPtrs = extPtrs[:count]
249+ extNames = extNames[:count]
250+ if result == vk.Success {
251+ err = nil
252+ break
253+ }
254+ err = checked("vkCreateDevice", result)
255+ if attempt == 0 {
256+ return
257+ }
198 }258 }
259+ r.queuePriority = chosen
199 r.dd, err = vk.LoadDeviceDispatch(r.id, r.device)260 r.dd, err = vk.LoadDeviceDispatch(r.id, r.device)
200 if err != nil {261 if err != nil {
201 err = fmt.Errorf("LoadDeviceDispatch: %w", err)262 err = fmt.Errorf("LoadDeviceDispatch: %w", err)
@@ -203,7 +264,7 @@ func New(width, height int) (r *Renderer, err error) {
203 }264 }
204 runtime.KeepAlive(extNames)265 runtime.KeepAlive(extNames)
205 runtime.KeepAlive(extPtrs)266 runtime.KeepAlive(extPtrs)
206- if len(extPtrs) > 0 {267+ if dmabufSupported {
207 // Clients need the render node to allocate on the right GPU.268 // Clients need the render node to allocate on the right GPU.
208 if sup := r.probeDMABuf(physical); sup.Device != 0 {269 if sup := r.probeDMABuf(physical); sup.Device != 0 {
209 r.dmabuf = sup270 r.dmabuf = sup
@@ -326,6 +387,10 @@ func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (ui
326 // It is diagnostic only and must be read by the renderer's owner goroutine.387 // It is diagnostic only and must be read by the renderer's owner goroutine.
327 func (r *Renderer) CopiedBytes() int { return r.copied }388 func (r *Renderer) CopiedBytes() int { return r.copied }
328 389
390+// QueuePriority is the global priority the queue got: realtime, high or
391+// default.
392+func (r *Renderer) QueuePriority() string { return r.queuePriority }
393+
329 func (r *Renderer) Pixels() *image.RGBA {394 func (r *Renderer) Pixels() *image.RGBA {
330 out := image.NewRGBA(image.Rect(0, 0, r.width, r.height))395 out := image.NewRGBA(image.Rect(0, 0, r.width, r.height))
331 if r.mapped == nil || r.readback() != nil {396 if r.mapped == nil || r.readback() != nil {
M internal/adapters/wayland/fifo.go +37 -1
@@ -208,6 +208,7 @@ type update struct {
208208 layout []childLayout
209209 deps []*update
210210 owner *surface
211+ implicit [4]*syncWait
211212 prev *update
212213 synced, bound bool
213214 xdg *xdgSurface
@@ -344,6 +345,15 @@ func (s *surface) queueUpdate() {
344345 *u = s.takePending()
345346 u.deps = deps
346347 u.owner, u.synced = s, s.effectivelySynced()
348+ if s.server.syncWait != nil && u.attached && u.buffer != nil && u.sync == nil {
349+ if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok {
350+ err := s.server.syncWait.watchImplicit(b.buf, &u.implicit)
351+ if err != nil {
352+ // Partial registrations were cancelled; do not block this update.
353+ s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait")
354+ }
355+ }
356+ }
347357 for _, ch := range s.sub.children {
348358 for i := len(ch.queue) - 1; i >= 0; i-- {
349359 candidate := ch.queue[i]
@@ -376,6 +386,10 @@ func (s *surface) applyUpdate(u *update) {
376386 }
377387 if u.buffer != nil && !u.buffer.Resource.Alive() {
378388 s.commitSkipped = true
389+ // No reader takes this buffer. A release point is safe to signal
390+ // only if the acquire has already signalled; dropSync owns that rule.
391+ s.dropSync(u.sync)
392+ u.sync = nil
379393 u.buffer, u.attached = nil, false
380394 // Scale, transform and crop describe the same retained buffer.
381395 u.scale = s.bufferScale
@@ -407,6 +421,9 @@ func (s *surface) dropQueue() {
407421 discard(fb)
408422 }
409423 s.dropSync(u.sync)
424+ for _, w := range u.implicit {
425+ s.server.syncWait.cancel(w)
426+ }
410427 if u.sync != nil {
411428 continue // explicit sync: the release point replaced release
412429 }
@@ -582,9 +599,28 @@ func (u *update) graphReady(now time.Time) bool {
582599 if u.prev != nil && !u.prev.graphReady(now) {
583600 return false
584601 }
585- if u.wait && s.barrier || !s.syncReady(u.sync) || s.tooEarly(u.at, now) {
602+ if u.wait && s.barrier || s.tooEarly(u.at, now) {
586603 return false
587604 }
605+ // A dead attachment is skipped by applyUpdate; waiting for its fence
606+ // would leave this update and every later one stuck behind it.
607+ if u.buffer != nil && !u.buffer.Resource.Alive() {
608+ for i, w := range u.implicit {
609+ if w != nil {
610+ s.server.syncWait.cancel(w)
611+ u.implicit[i] = nil
612+ }
613+ }
614+ } else {
615+ if !s.syncReady(u.sync) {
616+ return false
617+ }
618+ for _, w := range u.implicit {
619+ if w != nil && !s.server.syncWait.fired(w) {
620+ return false
621+ }
622+ }
623+ }
588624 for _, dep := range u.deps {
589625 if !dep.owner.destroyed && !dep.graphReady(now) {
590626 return false
@@ -208,6 +208,7 @@ type update struct {
208 layout []childLayout208 layout []childLayout
209 deps []*update209 deps []*update
210 owner *surface210 owner *surface
211+ implicit [4]*syncWait
211 prev *update212 prev *update
212 synced, bound bool213 synced, bound bool
213 xdg *xdgSurface214 xdg *xdgSurface
@@ -344,6 +345,15 @@ func (s *surface) queueUpdate() {
344 *u = s.takePending()345 *u = s.takePending()
345 u.deps = deps346 u.deps = deps
346 u.owner, u.synced = s, s.effectivelySynced()347 u.owner, u.synced = s, s.effectivelySynced()
348+ if s.server.syncWait != nil && u.attached && u.buffer != nil && u.sync == nil {
349+ if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok {
350+ err := s.server.syncWait.watchImplicit(b.buf, &u.implicit)
351+ if err != nil {
352+ // Partial registrations were cancelled; do not block this update.
353+ s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait")
354+ }
355+ }
356+ }
347 for _, ch := range s.sub.children {357 for _, ch := range s.sub.children {
348 for i := len(ch.queue) - 1; i >= 0; i-- {358 for i := len(ch.queue) - 1; i >= 0; i-- {
349 candidate := ch.queue[i]359 candidate := ch.queue[i]
@@ -376,6 +386,10 @@ func (s *surface) applyUpdate(u *update) {
376 }386 }
377 if u.buffer != nil && !u.buffer.Resource.Alive() {387 if u.buffer != nil && !u.buffer.Resource.Alive() {
378 s.commitSkipped = true388 s.commitSkipped = true
389+ // No reader takes this buffer. A release point is safe to signal
390+ // only if the acquire has already signalled; dropSync owns that rule.
391+ s.dropSync(u.sync)
392+ u.sync = nil
379 u.buffer, u.attached = nil, false393 u.buffer, u.attached = nil, false
380 // Scale, transform and crop describe the same retained buffer.394 // Scale, transform and crop describe the same retained buffer.
381 u.scale = s.bufferScale395 u.scale = s.bufferScale
@@ -407,6 +421,9 @@ func (s *surface) dropQueue() {
407 discard(fb)421 discard(fb)
408 }422 }
409 s.dropSync(u.sync)423 s.dropSync(u.sync)
424+ for _, w := range u.implicit {
425+ s.server.syncWait.cancel(w)
426+ }
410 if u.sync != nil {427 if u.sync != nil {
411 continue // explicit sync: the release point replaced release428 continue // explicit sync: the release point replaced release
412 }429 }
@@ -582,9 +599,28 @@ func (u *update) graphReady(now time.Time) bool {
582 if u.prev != nil && !u.prev.graphReady(now) {599 if u.prev != nil && !u.prev.graphReady(now) {
583 return false600 return false
584 }601 }
585- if u.wait && s.barrier || !s.syncReady(u.sync) || s.tooEarly(u.at, now) {602+ if u.wait && s.barrier || s.tooEarly(u.at, now) {
586 return false603 return false
587 }604 }
605+ // A dead attachment is skipped by applyUpdate; waiting for its fence
606+ // would leave this update and every later one stuck behind it.
607+ if u.buffer != nil && !u.buffer.Resource.Alive() {
608+ for i, w := range u.implicit {
609+ if w != nil {
610+ s.server.syncWait.cancel(w)
611+ u.implicit[i] = nil
612+ }
613+ }
614+ } else {
615+ if !s.syncReady(u.sync) {
616+ return false
617+ }
618+ for _, w := range u.implicit {
619+ if w != nil && !s.server.syncWait.fired(w) {
620+ return false
621+ }
622+ }
623+ }
588 for _, dep := range u.deps {624 for _, dep := range u.deps {
589 if !dep.owner.destroyed && !dep.graphReady(now) {625 if !dep.owner.destroyed && !dep.graphReady(now) {
590 return false626 return false
M internal/adapters/wayland/server.go +9 -0
@@ -249,6 +249,14 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
249249 s.syncDev = node
250250 }
251251 }
252+ // Implicit dma-buf fences also need this waiter when explicit sync is off.
253+ if s.syncWait == nil {
254+ if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {
255+ cleanup()
256+ d.Close()
257+ return nil, err
258+ }
259+ }
252260 s.colorIdentity = map[string]uint64{}
253261 s.colorDescriptions = map[*server.Resource]colorDescription{}
254262 s.colorOutputs = map[*colorOutput]struct{}{}
@@ -263,6 +271,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
263271 }
264272 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
265273 }
274+ s.syncWait.log = log
266275 s.cleanup = func() {
267276 if s.seat.keymapFD >= 0 {
268277 unix.Close(s.seat.keymapFD)
@@ -249,6 +249,14 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
249 s.syncDev = node249 s.syncDev = node
250 }250 }
251 }251 }
252+ // Implicit dma-buf fences also need this waiter when explicit sync is off.
253+ if s.syncWait == nil {
254+ if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {
255+ cleanup()
256+ d.Close()
257+ return nil, err
258+ }
259+ }
252 s.colorIdentity = map[string]uint64{}260 s.colorIdentity = map[string]uint64{}
253 s.colorDescriptions = map[*server.Resource]colorDescription{}261 s.colorDescriptions = map[*server.Resource]colorDescription{}
254 s.colorOutputs = map[*colorOutput]struct{}{}262 s.colorOutputs = map[*colorOutput]struct{}{}
@@ -263,6 +271,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
263 }271 }
264 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap272 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
265 }273 }
274+ s.syncWait.log = log
266 s.cleanup = func() {275 s.cleanup = func() {
267 if s.seat.keymapFD >= 0 {276 if s.seat.keymapFD >= 0 {
268 unix.Close(s.seat.keymapFD)277 unix.Close(s.seat.keymapFD)
M internal/adapters/wayland/surface.go +2 -1
@@ -190,6 +190,7 @@ func (s *surface) contentWithColor() ports.SurfaceContent {
190190 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
191191 c := s.contentWithColor()
192192 c.ID = id
193+ c.ContentType = ports.ContentType(s.contentKind)
193194 if s.treeDirty || s.cachedTree == nil {
194195 count := s.treeCount()
195196 children := make([]ports.Subsurface, 0, count)
@@ -364,7 +365,7 @@ func (s *surface) applyCommit(u *update) {
364365 }
365366 if s.contentKind != u.kind {
366367 s.contentKind = u.kind
367- s.server.log.Info().Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")
368+ s.server.log.Info().Str("component", "wayland").Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")
368369 }
369370 if u.scale > 0 {
370371 s.bufferScale = u.scale
@@ -190,6 +190,7 @@ func (s *surface) contentWithColor() ports.SurfaceContent {
190 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {190 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
191 c := s.contentWithColor()191 c := s.contentWithColor()
192 c.ID = id192 c.ID = id
193+ c.ContentType = ports.ContentType(s.contentKind)
193 if s.treeDirty || s.cachedTree == nil {194 if s.treeDirty || s.cachedTree == nil {
194 count := s.treeCount()195 count := s.treeCount()
195 children := make([]ports.Subsurface, 0, count)196 children := make([]ports.Subsurface, 0, count)
@@ -364,7 +365,7 @@ func (s *surface) applyCommit(u *update) {
364 }365 }
365 if s.contentKind != u.kind {366 if s.contentKind != u.kind {
366 s.contentKind = u.kind367 s.contentKind = u.kind
367- s.server.log.Info().Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")368+ s.server.log.Info().Str("component", "wayland").Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")
368 }369 }
369 if u.scale > 0 {370 if u.scale > 0 {
370 s.bufferScale = u.scale371 s.bufferScale = u.scale
M internal/adapters/wayland/syncdev.go +25 -3
@@ -18,7 +18,9 @@ type syncobjDevice interface {
1818 exportSyncFile(h uint32, point uint64) (*os.File, error)
1919 // signal signals a timeline point (a release point).
2020 signal(h uint32, point uint64) error
21- // eventfd writes efd once the point has a fence (WAIT_AVAILABLE).
21+ // signalled checks a point without blocking.
22+ signalled(h uint32, point uint64) (bool, error)
23+ // eventfd writes efd once the point is signalled.
2224 eventfd(h uint32, point uint64, efd int) error
2325 destroy(h uint32) error
2426 }
@@ -27,13 +29,13 @@ const (
2729 ioctlSyncobjDestroy = 0xC00864C0 // DRM_IOWR(0xC0, struct drm_syncobj_destroy)
2830 ioctlSyncobjHandleToFD = 0xC01864C1 // DRM_IOWR(0xC1, struct drm_syncobj_handle)
2931 ioctlSyncobjFDToHandle = 0xC01864C2 // DRM_IOWR(0xC2, struct drm_syncobj_handle)
32+ ioctlSyncobjTimelineWait = 0xC02864CA // DRM_IOWR(0xCA, struct drm_syncobj_timeline_wait)
3033 ioctlSyncobjTimelineSignal = 0xC01864CD // DRM_IOWR(0xCD, struct drm_syncobj_timeline_array)
3134 ioctlSyncobjEventfd = 0xC01864CF // DRM_IOWR(0xCF, struct drm_syncobj_eventfd)
3235 ioctlGetCap = 0xC010640C // DRM_IOWR(0x0C, struct drm_get_cap)
3336
3437 syncobjExportSyncFile = 1 << 0
3538 syncobjTimeline = 1 << 1
36- syncobjWaitAvailable = 1 << 2
3739 capSyncobjTimeline = 0x14
3840 )
3941
@@ -51,6 +53,14 @@ type syncobjTimelineArray struct {
5153 count, flags uint32
5254 }
5355
56+type syncobjTimelineWait struct {
57+ handles, points uint64
58+ timeout int64
59+ count, flags uint32
60+ first uint32
61+ pad uint32
62+}
63+
5464 type syncobjEventfd struct {
5565 handle, flags uint32
5666 point uint64
@@ -117,8 +127,20 @@ func (n *renderNode) signal(h uint32, point uint64) error {
117127 return err
118128 }
119129
130+func (n *renderNode) signalled(h uint32, point uint64) (bool, error) {
131+ handles, points := [1]uint32{h}, [1]uint64{point}
132+ a := syncobjTimelineWait{handles: uint64(uintptr(unsafe.Pointer(&handles))), points: uint64(uintptr(unsafe.Pointer(&points))), count: 1}
133+ err := ioctl(n.fd, ioctlSyncobjTimelineWait, unsafe.Pointer(&a))
134+ runtime.KeepAlive(&handles)
135+ runtime.KeepAlive(&points)
136+ if err == unix.ETIME {
137+ return false, nil
138+ }
139+ return err == nil, err
140+}
141+
120142 func (n *renderNode) eventfd(h uint32, point uint64, efd int) error {
121- a := syncobjEventfd{handle: h, flags: syncobjWaitAvailable, point: point, fd: int32(efd)}
143+ a := syncobjEventfd{handle: h, point: point, fd: int32(efd)}
122144 return ioctl(n.fd, ioctlSyncobjEventfd, unsafe.Pointer(&a))
123145 }
124146
@@ -18,7 +18,9 @@ type syncobjDevice interface {
18 exportSyncFile(h uint32, point uint64) (*os.File, error)18 exportSyncFile(h uint32, point uint64) (*os.File, error)
19 // signal signals a timeline point (a release point).19 // signal signals a timeline point (a release point).
20 signal(h uint32, point uint64) error20 signal(h uint32, point uint64) error
21- // eventfd writes efd once the point has a fence (WAIT_AVAILABLE).21+ // signalled checks a point without blocking.
22+ signalled(h uint32, point uint64) (bool, error)
23+ // eventfd writes efd once the point is signalled.
22 eventfd(h uint32, point uint64, efd int) error24 eventfd(h uint32, point uint64, efd int) error
23 destroy(h uint32) error25 destroy(h uint32) error
24 }26 }
@@ -27,13 +29,13 @@ const (
27 ioctlSyncobjDestroy = 0xC00864C0 // DRM_IOWR(0xC0, struct drm_syncobj_destroy)29 ioctlSyncobjDestroy = 0xC00864C0 // DRM_IOWR(0xC0, struct drm_syncobj_destroy)
28 ioctlSyncobjHandleToFD = 0xC01864C1 // DRM_IOWR(0xC1, struct drm_syncobj_handle)30 ioctlSyncobjHandleToFD = 0xC01864C1 // DRM_IOWR(0xC1, struct drm_syncobj_handle)
29 ioctlSyncobjFDToHandle = 0xC01864C2 // DRM_IOWR(0xC2, struct drm_syncobj_handle)31 ioctlSyncobjFDToHandle = 0xC01864C2 // DRM_IOWR(0xC2, struct drm_syncobj_handle)
32+ ioctlSyncobjTimelineWait = 0xC02864CA // DRM_IOWR(0xCA, struct drm_syncobj_timeline_wait)
30 ioctlSyncobjTimelineSignal = 0xC01864CD // DRM_IOWR(0xCD, struct drm_syncobj_timeline_array)33 ioctlSyncobjTimelineSignal = 0xC01864CD // DRM_IOWR(0xCD, struct drm_syncobj_timeline_array)
31 ioctlSyncobjEventfd = 0xC01864CF // DRM_IOWR(0xCF, struct drm_syncobj_eventfd)34 ioctlSyncobjEventfd = 0xC01864CF // DRM_IOWR(0xCF, struct drm_syncobj_eventfd)
32 ioctlGetCap = 0xC010640C // DRM_IOWR(0x0C, struct drm_get_cap)35 ioctlGetCap = 0xC010640C // DRM_IOWR(0x0C, struct drm_get_cap)
33 36
34 syncobjExportSyncFile = 1 << 037 syncobjExportSyncFile = 1 << 0
35 syncobjTimeline = 1 << 138 syncobjTimeline = 1 << 1
36- syncobjWaitAvailable = 1 << 2
37 capSyncobjTimeline = 0x1439 capSyncobjTimeline = 0x14
38 )40 )
39 41
@@ -51,6 +53,14 @@ type syncobjTimelineArray struct {
51 count, flags uint3253 count, flags uint32
52 }54 }
53 55
56+type syncobjTimelineWait struct {
57+ handles, points uint64
58+ timeout int64
59+ count, flags uint32
60+ first uint32
61+ pad uint32
62+}
63+
54 type syncobjEventfd struct {64 type syncobjEventfd struct {
55 handle, flags uint3265 handle, flags uint32
56 point uint6466 point uint64
@@ -117,8 +127,20 @@ func (n *renderNode) signal(h uint32, point uint64) error {
117 return err127 return err
118 }128 }
119 129
130+func (n *renderNode) signalled(h uint32, point uint64) (bool, error) {
131+ handles, points := [1]uint32{h}, [1]uint64{point}
132+ a := syncobjTimelineWait{handles: uint64(uintptr(unsafe.Pointer(&handles))), points: uint64(uintptr(unsafe.Pointer(&points))), count: 1}
133+ err := ioctl(n.fd, ioctlSyncobjTimelineWait, unsafe.Pointer(&a))
134+ runtime.KeepAlive(&handles)
135+ runtime.KeepAlive(&points)
136+ if err == unix.ETIME {
137+ return false, nil
138+ }
139+ return err == nil, err
140+}
141+
120 func (n *renderNode) eventfd(h uint32, point uint64, efd int) error {142 func (n *renderNode) eventfd(h uint32, point uint64, efd int) error {
121- a := syncobjEventfd{handle: h, flags: syncobjWaitAvailable, point: point, fd: int32(efd)}143+ a := syncobjEventfd{handle: h, point: point, fd: int32(efd)}
122 return ioctl(n.fd, ioctlSyncobjEventfd, unsafe.Pointer(&a))144 return ioctl(n.fd, ioctlSyncobjEventfd, unsafe.Pointer(&a))
123 }145 }
124 146
M internal/adapters/wayland/syncobj.go +79 -14
@@ -7,9 +7,11 @@ import (
77 "sync"
88 "sync/atomic"
99
10+ "github.com/bnema/neferwl/internal/ports"
1011 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
1112 "github.com/bnema/purego-libwayland/protocol/wayland"
1213 "github.com/bnema/purego-libwayland/server"
14+ "github.com/bnema/zerowrap"
1315 "golang.org/x/sys/unix"
1416 )
1517
@@ -188,15 +190,17 @@ func (s *surface) pendingIsSHM() bool {
188190 }
189191
190192 // syncWaiter watches acquire points: each gets an eventfd the kernel
191-// writes once the point has a fence, and the pacer is woken to apply the
193+// writes once the point is signalled, and the pacer is woken to apply the
192194 // commits that waited for it.
193195 type syncWaiter struct {
194- mu sync.Mutex
195- ready map[*syncWait]bool
196- wake func()
197- pipeR *os.File
198- pipeW *os.File
199- added []*syncWait // new waits for run (under mu)
196+ mu sync.Mutex
197+ ready map[*syncWait]bool
198+ wake func()
199+ pipeR *os.File
200+ pipeW *os.File
201+ added []*syncWait // new waits for run (under mu)
202+ log zerowrap.Logger
203+ pollError sync.Once
200204 }
201205
202206 // syncWait is one acquire point a queued commit waits for.
@@ -211,11 +215,15 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
211215 if err != nil {
212216 return nil, err
213217 }
214- return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w}, nil
218+ return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
215219 }
216220
217221 // watch registers an eventfd for p; the wait is ready once it fires.
218222 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
223+ ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
224+ if err != nil || ready {
225+ return &syncWait{efd: -1, done: ready}, err
226+ }
219227 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
220228 if err != nil {
221229 return nil, err
@@ -224,15 +232,63 @@ func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
224232 unix.Close(efd)
225233 return nil, err
226234 }
227- w := &syncWait{efd: efd}
235+ return sw.add(efd), nil
236+}
237+
238+// watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
239+func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
240+ var fds [4]unix.PollFd
241+ for i, p := range b.Planes {
242+ if i >= len(fds) {
243+ break
244+ }
245+ fds[i] = unix.PollFd{Fd: int32(p.File.Fd()), Events: unix.POLLIN}
246+ }
247+ planes := fds[:min(len(b.Planes), len(fds))]
248+ _, err := unix.Poll(planes, 0)
249+ for errors.Is(err, unix.EINTR) { // a signal, not a fence error
250+ _, err = unix.Poll(planes, 0)
251+ }
252+ if err != nil {
253+ return err
254+ }
255+ for i, fd := range planes {
256+ if fd.Revents&unix.POLLIN != 0 {
257+ continue
258+ }
259+ if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
260+ sw.warnPollError()
261+ continue
262+ }
263+ dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0)
264+ if err != nil {
265+ for j, w := range waits {
266+ sw.cancel(w)
267+ waits[j] = nil
268+ }
269+ return err
270+ }
271+ waits[i] = sw.add(dup)
272+ }
273+ return nil
274+}
275+
276+func (sw *syncWaiter) warnPollError() {
277+ sw.pollError.Do(func() {
278+ sw.log.Warn().Str("component", "wayland").Msg("implicit fence poll error")
279+ })
280+}
281+
282+func (sw *syncWaiter) add(fd int) *syncWait {
283+ w := &syncWait{efd: fd}
228284 sw.mu.Lock()
229285 sw.added = append(sw.added, w)
230286 sw.mu.Unlock()
231287 _, _ = sw.pipeW.Write([]byte{0})
232- return w, nil
288+ return w
233289 }
234290
235-// fired reports whether w's point has a fence, and forgets w once it has.
291+// fired reports whether w's point is signalled, and forgets w once it has.
236292 func (sw *syncWaiter) fired(w *syncWait) bool {
237293 sw.mu.Lock()
238294 defer sw.mu.Unlock()
@@ -245,6 +301,9 @@ func (sw *syncWaiter) fired(w *syncWait) bool {
245301
246302 // cancel stops watching w (the commit was dropped).
247303 func (sw *syncWaiter) cancel(w *syncWait) {
304+ if w == nil || w.efd < 0 {
305+ return
306+ }
248307 w.cancelled.Store(true)
249308 sw.mu.Lock()
250309 delete(sw.ready, w)
@@ -294,15 +353,21 @@ func (sw *syncWaiter) run(ctx context.Context) {
294353 unix.Close(w.efd)
295354 continue
296355 }
297- if fds[i+1].Revents == 0 {
356+ revents := fds[i+1].Revents
357+ if revents&unix.POLLIN == 0 && revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) == 0 {
298358 kept = append(kept, w)
299359 continue
300360 }
361+ if revents&unix.POLLIN == 0 {
362+ sw.warnPollError()
363+ }
301364 unix.Close(w.efd)
302365 sw.mu.Lock()
303- sw.ready[w] = true
366+ if !w.cancelled.Load() {
367+ sw.ready[w] = true
368+ fired = true
369+ }
304370 sw.mu.Unlock()
305- fired = true
306371 }
307372 waits = kept
308373 // New waits join the next poll.
@@ -7,9 +7,11 @@ import (
7 "sync"7 "sync"
8 "sync/atomic"8 "sync/atomic"
9 9
10+ "github.com/bnema/neferwl/internal/ports"
10 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"11 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
11 "github.com/bnema/purego-libwayland/protocol/wayland"12 "github.com/bnema/purego-libwayland/protocol/wayland"
12 "github.com/bnema/purego-libwayland/server"13 "github.com/bnema/purego-libwayland/server"
14+ "github.com/bnema/zerowrap"
13 "golang.org/x/sys/unix"15 "golang.org/x/sys/unix"
14 )16 )
15 17
@@ -188,15 +190,17 @@ func (s *surface) pendingIsSHM() bool {
188 }190 }
189 191
190 // syncWaiter watches acquire points: each gets an eventfd the kernel192 // syncWaiter watches acquire points: each gets an eventfd the kernel
191-// writes once the point has a fence, and the pacer is woken to apply the193+// writes once the point is signalled, and the pacer is woken to apply the
192 // commits that waited for it.194 // commits that waited for it.
193 type syncWaiter struct {195 type syncWaiter struct {
194- mu sync.Mutex196+ mu sync.Mutex
195- ready map[*syncWait]bool197+ ready map[*syncWait]bool
196- wake func()198+ wake func()
197- pipeR *os.File199+ pipeR *os.File
198- pipeW *os.File200+ pipeW *os.File
199- added []*syncWait // new waits for run (under mu)201+ added []*syncWait // new waits for run (under mu)
202+ log zerowrap.Logger
203+ pollError sync.Once
200 }204 }
201 205
202 // syncWait is one acquire point a queued commit waits for.206 // syncWait is one acquire point a queued commit waits for.
@@ -211,11 +215,15 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
211 if err != nil {215 if err != nil {
212 return nil, err216 return nil, err
213 }217 }
214- return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w}, nil218+ return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
215 }219 }
216 220
217 // watch registers an eventfd for p; the wait is ready once it fires.221 // watch registers an eventfd for p; the wait is ready once it fires.
218 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {222 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
223+ ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
224+ if err != nil || ready {
225+ return &syncWait{efd: -1, done: ready}, err
226+ }
219 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)227 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
220 if err != nil {228 if err != nil {
221 return nil, err229 return nil, err
@@ -224,15 +232,63 @@ func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
224 unix.Close(efd)232 unix.Close(efd)
225 return nil, err233 return nil, err
226 }234 }
227- w := &syncWait{efd: efd}235+ return sw.add(efd), nil
236+}
237+
238+// watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
239+func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
240+ var fds [4]unix.PollFd
241+ for i, p := range b.Planes {
242+ if i >= len(fds) {
243+ break
244+ }
245+ fds[i] = unix.PollFd{Fd: int32(p.File.Fd()), Events: unix.POLLIN}
246+ }
247+ planes := fds[:min(len(b.Planes), len(fds))]
248+ _, err := unix.Poll(planes, 0)
249+ for errors.Is(err, unix.EINTR) { // a signal, not a fence error
250+ _, err = unix.Poll(planes, 0)
251+ }
252+ if err != nil {
253+ return err
254+ }
255+ for i, fd := range planes {
256+ if fd.Revents&unix.POLLIN != 0 {
257+ continue
258+ }
259+ if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
260+ sw.warnPollError()
261+ continue
262+ }
263+ dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0)
264+ if err != nil {
265+ for j, w := range waits {
266+ sw.cancel(w)
267+ waits[j] = nil
268+ }
269+ return err
270+ }
271+ waits[i] = sw.add(dup)
272+ }
273+ return nil
274+}
275+
276+func (sw *syncWaiter) warnPollError() {
277+ sw.pollError.Do(func() {
278+ sw.log.Warn().Str("component", "wayland").Msg("implicit fence poll error")
279+ })
280+}
281+
282+func (sw *syncWaiter) add(fd int) *syncWait {
283+ w := &syncWait{efd: fd}
228 sw.mu.Lock()284 sw.mu.Lock()
229 sw.added = append(sw.added, w)285 sw.added = append(sw.added, w)
230 sw.mu.Unlock()286 sw.mu.Unlock()
231 _, _ = sw.pipeW.Write([]byte{0})287 _, _ = sw.pipeW.Write([]byte{0})
232- return w, nil288+ return w
233 }289 }
234 290
235-// fired reports whether w's point has a fence, and forgets w once it has.291+// fired reports whether w's point is signalled, and forgets w once it has.
236 func (sw *syncWaiter) fired(w *syncWait) bool {292 func (sw *syncWaiter) fired(w *syncWait) bool {
237 sw.mu.Lock()293 sw.mu.Lock()
238 defer sw.mu.Unlock()294 defer sw.mu.Unlock()
@@ -245,6 +301,9 @@ func (sw *syncWaiter) fired(w *syncWait) bool {
245 301
246 // cancel stops watching w (the commit was dropped).302 // cancel stops watching w (the commit was dropped).
247 func (sw *syncWaiter) cancel(w *syncWait) {303 func (sw *syncWaiter) cancel(w *syncWait) {
304+ if w == nil || w.efd < 0 {
305+ return
306+ }
248 w.cancelled.Store(true)307 w.cancelled.Store(true)
249 sw.mu.Lock()308 sw.mu.Lock()
250 delete(sw.ready, w)309 delete(sw.ready, w)
@@ -294,15 +353,21 @@ func (sw *syncWaiter) run(ctx context.Context) {
294 unix.Close(w.efd)353 unix.Close(w.efd)
295 continue354 continue
296 }355 }
297- if fds[i+1].Revents == 0 {356+ revents := fds[i+1].Revents
357+ if revents&unix.POLLIN == 0 && revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) == 0 {
298 kept = append(kept, w)358 kept = append(kept, w)
299 continue359 continue
300 }360 }
361+ if revents&unix.POLLIN == 0 {
362+ sw.warnPollError()
363+ }
301 unix.Close(w.efd)364 unix.Close(w.efd)
302 sw.mu.Lock()365 sw.mu.Lock()
303- sw.ready[w] = true366+ if !w.cancelled.Load() {
367+ sw.ready[w] = true
368+ fired = true
369+ }
304 sw.mu.Unlock()370 sw.mu.Unlock()
305- fired = true
306 }371 }
307 waits = kept372 waits = kept
308 // New waits join the next poll.373 // New waits join the next poll.
M internal/adapters/wayland/syncobj_device_mock_test.go +66 -0
@@ -344,3 +344,69 @@ func (_c *mocksyncobjDevice_signal_Call) RunAndReturn(run func(h uint32, point u
344344 _c.Call.Return(run)
345345 return _c
346346 }
347+
348+// signalled provides a mock function for the type mocksyncobjDevice
349+func (_mock *mocksyncobjDevice) signalled(h uint32, point uint64) (bool, error) {
350+ ret := _mock.Called(h, point)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for signalled")
354+ }
355+
356+ var r0 bool
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(uint32, uint64) (bool, error)); ok {
359+ return returnFunc(h, point)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(uint32, uint64) bool); ok {
362+ r0 = returnFunc(h, point)
363+ } else {
364+ r0 = ret.Get(0).(bool)
365+ }
366+ if returnFunc, ok := ret.Get(1).(func(uint32, uint64) error); ok {
367+ r1 = returnFunc(h, point)
368+ } else {
369+ r1 = ret.Error(1)
370+ }
371+ return r0, r1
372+}
373+
374+// mocksyncobjDevice_signalled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'signalled'
375+type mocksyncobjDevice_signalled_Call struct {
376+ *mock.Call
377+}
378+
379+// signalled is a helper method to define mock.On call
380+// - h uint32
381+// - point uint64
382+func (_e *mocksyncobjDevice_Expecter) signalled(h any, point any) *mocksyncobjDevice_signalled_Call {
383+ return &mocksyncobjDevice_signalled_Call{Call: _e.mock.On("signalled", h, point)}
384+}
385+
386+func (_c *mocksyncobjDevice_signalled_Call) Run(run func(h uint32, point uint64)) *mocksyncobjDevice_signalled_Call {
387+ _c.Call.Run(func(args mock.Arguments) {
388+ var arg0 uint32
389+ if args[0] != nil {
390+ arg0 = args[0].(uint32)
391+ }
392+ var arg1 uint64
393+ if args[1] != nil {
394+ arg1 = args[1].(uint64)
395+ }
396+ run(
397+ arg0,
398+ arg1,
399+ )
400+ })
401+ return _c
402+}
403+
404+func (_c *mocksyncobjDevice_signalled_Call) Return(b bool, err error) *mocksyncobjDevice_signalled_Call {
405+ _c.Call.Return(b, err)
406+ return _c
407+}
408+
409+func (_c *mocksyncobjDevice_signalled_Call) RunAndReturn(run func(h uint32, point uint64) (bool, error)) *mocksyncobjDevice_signalled_Call {
410+ _c.Call.Return(run)
411+ return _c
412+}
@@ -344,3 +344,69 @@ func (_c *mocksyncobjDevice_signal_Call) RunAndReturn(run func(h uint32, point u
344 _c.Call.Return(run)344 _c.Call.Return(run)
345 return _c345 return _c
346 }346 }
347+
348+// signalled provides a mock function for the type mocksyncobjDevice
349+func (_mock *mocksyncobjDevice) signalled(h uint32, point uint64) (bool, error) {
350+ ret := _mock.Called(h, point)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for signalled")
354+ }
355+
356+ var r0 bool
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(uint32, uint64) (bool, error)); ok {
359+ return returnFunc(h, point)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(uint32, uint64) bool); ok {
362+ r0 = returnFunc(h, point)
363+ } else {
364+ r0 = ret.Get(0).(bool)
365+ }
366+ if returnFunc, ok := ret.Get(1).(func(uint32, uint64) error); ok {
367+ r1 = returnFunc(h, point)
368+ } else {
369+ r1 = ret.Error(1)
370+ }
371+ return r0, r1
372+}
373+
374+// mocksyncobjDevice_signalled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'signalled'
375+type mocksyncobjDevice_signalled_Call struct {
376+ *mock.Call
377+}
378+
379+// signalled is a helper method to define mock.On call
380+// - h uint32
381+// - point uint64
382+func (_e *mocksyncobjDevice_Expecter) signalled(h any, point any) *mocksyncobjDevice_signalled_Call {
383+ return &mocksyncobjDevice_signalled_Call{Call: _e.mock.On("signalled", h, point)}
384+}
385+
386+func (_c *mocksyncobjDevice_signalled_Call) Run(run func(h uint32, point uint64)) *mocksyncobjDevice_signalled_Call {
387+ _c.Call.Run(func(args mock.Arguments) {
388+ var arg0 uint32
389+ if args[0] != nil {
390+ arg0 = args[0].(uint32)
391+ }
392+ var arg1 uint64
393+ if args[1] != nil {
394+ arg1 = args[1].(uint64)
395+ }
396+ run(
397+ arg0,
398+ arg1,
399+ )
400+ })
401+ return _c
402+}
403+
404+func (_c *mocksyncobjDevice_signalled_Call) Return(b bool, err error) *mocksyncobjDevice_signalled_Call {
405+ _c.Call.Return(b, err)
406+ return _c
407+}
408+
409+func (_c *mocksyncobjDevice_signalled_Call) RunAndReturn(run func(h uint32, point uint64) (bool, error)) *mocksyncobjDevice_signalled_Call {
410+ _c.Call.Return(run)
411+ return _c
412+}
M internal/adapters/wayland/syncobj_test.go +38 -0
@@ -31,6 +31,7 @@ func TestSyncobjStructSizes(t *testing.T) {
3131 {"handle_to_fd", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjHandleToFD},
3232 {"fd_to_handle", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjFDToHandle},
3333 {"timeline_signal", unsafe.Sizeof(syncobjTimelineArray{}), ioctlSyncobjTimelineSignal},
34+ {"timeline_wait", unsafe.Sizeof(syncobjTimelineWait{}), ioctlSyncobjTimelineWait},
3435 {"eventfd", unsafe.Sizeof(syncobjEventfd{}), ioctlSyncobjEventfd},
3536 {"get_cap", unsafe.Sizeof(drmCap{}), ioctlGetCap},
3637 } {
@@ -66,6 +67,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
6667 h.dev = newMocksyncobjDevice(t)
6768 h.dev.EXPECT().fdToHandle(mock.Anything).Return(7, nil).Maybe()
6869 h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe()
70+ h.dev.EXPECT().signalled(uint32(7), mock.Anything).Return(false, nil).Maybe()
6971 h.dev.EXPECT().eventfd(uint32(7), mock.Anything, mock.Anything).RunAndReturn(func(_ uint32, point uint64, efd int) error {
7072 // Keep a duplicate: the waiter closes its own when it fires.
7173 dup, err := unix.Dup(efd)
@@ -355,6 +357,42 @@ func TestQueuedCommitReadinessGatesInOrder(t *testing.T) {
355357 }
356358 }
357359
360+// A destroyed queued buffer must not hold later commits behind its acquire.
361+// Its release point cannot be advanced before the acquire is ready.
362+func TestSyncobjDestroyedQueuedAcquire(t *testing.T) {
363+ h := newSyncHarness(t)
364+ h.releases = map[uint32]*syncReleaseProxy{}
365+ first, dead, next := h.dmabuf(), h.dmabuf(), h.dmabuf()
366+ h.commit(first, 1, 2)
367+ h.fire(1)
368+ if _, ok := h.content(2 * time.Second); !ok {
369+ t.Fatal("initial content missing")
370+ }
371+ fm := bindProtocol(t, h.c, "wp_fifo_manager_v1")
372+ f := h.c.AllocateID()
373+ registerProtocol(t, h.c, f)
374+ requestProtocol(t, h.c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, h.surf)
375+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestSetBarrier)
376+ h.commit(dead, 3, 4)
377+ requestProtocol(t, h.c, dead, wayland.BufferRequestDestroy)
378+ if err := h.c.Roundtrip(); err != nil {
379+ t.Fatal(err)
380+ }
381+ // The dead attachment is skipped; it does not replace the current one.
382+ // A later attachment is queued while the old fence remains unsignalled.
383+ h.commit(next, 5, 6)
384+ h.fire(5)
385+ got, ok := h.content(2 * time.Second)
386+ if !ok || got.DMABuf == nil {
387+ t.Fatalf("later content blocked: %+v", got)
388+ }
389+ for _, p := range h.signalled() {
390+ if p == 4 {
391+ t.Fatal("skipped buffer release signalled before acquire")
392+ }
393+ }
394+}
395+
358396 func TestSyncobjProtocolErrors(t *testing.T) {
359397 for _, tc := range []struct {
360398 name string
@@ -31,6 +31,7 @@ func TestSyncobjStructSizes(t *testing.T) {
31 {"handle_to_fd", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjHandleToFD},31 {"handle_to_fd", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjHandleToFD},
32 {"fd_to_handle", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjFDToHandle},32 {"fd_to_handle", unsafe.Sizeof(syncobjHandle{}), ioctlSyncobjFDToHandle},
33 {"timeline_signal", unsafe.Sizeof(syncobjTimelineArray{}), ioctlSyncobjTimelineSignal},33 {"timeline_signal", unsafe.Sizeof(syncobjTimelineArray{}), ioctlSyncobjTimelineSignal},
34+ {"timeline_wait", unsafe.Sizeof(syncobjTimelineWait{}), ioctlSyncobjTimelineWait},
34 {"eventfd", unsafe.Sizeof(syncobjEventfd{}), ioctlSyncobjEventfd},35 {"eventfd", unsafe.Sizeof(syncobjEventfd{}), ioctlSyncobjEventfd},
35 {"get_cap", unsafe.Sizeof(drmCap{}), ioctlGetCap},36 {"get_cap", unsafe.Sizeof(drmCap{}), ioctlGetCap},
36 } {37 } {
@@ -66,6 +67,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
66 h.dev = newMocksyncobjDevice(t)67 h.dev = newMocksyncobjDevice(t)
67 h.dev.EXPECT().fdToHandle(mock.Anything).Return(7, nil).Maybe()68 h.dev.EXPECT().fdToHandle(mock.Anything).Return(7, nil).Maybe()
68 h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe()69 h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe()
70+ h.dev.EXPECT().signalled(uint32(7), mock.Anything).Return(false, nil).Maybe()
69 h.dev.EXPECT().eventfd(uint32(7), mock.Anything, mock.Anything).RunAndReturn(func(_ uint32, point uint64, efd int) error {71 h.dev.EXPECT().eventfd(uint32(7), mock.Anything, mock.Anything).RunAndReturn(func(_ uint32, point uint64, efd int) error {
70 // Keep a duplicate: the waiter closes its own when it fires.72 // Keep a duplicate: the waiter closes its own when it fires.
71 dup, err := unix.Dup(efd)73 dup, err := unix.Dup(efd)
@@ -355,6 +357,42 @@ func TestQueuedCommitReadinessGatesInOrder(t *testing.T) {
355 }357 }
356 }358 }
357 359
360+// A destroyed queued buffer must not hold later commits behind its acquire.
361+// Its release point cannot be advanced before the acquire is ready.
362+func TestSyncobjDestroyedQueuedAcquire(t *testing.T) {
363+ h := newSyncHarness(t)
364+ h.releases = map[uint32]*syncReleaseProxy{}
365+ first, dead, next := h.dmabuf(), h.dmabuf(), h.dmabuf()
366+ h.commit(first, 1, 2)
367+ h.fire(1)
368+ if _, ok := h.content(2 * time.Second); !ok {
369+ t.Fatal("initial content missing")
370+ }
371+ fm := bindProtocol(t, h.c, "wp_fifo_manager_v1")
372+ f := h.c.AllocateID()
373+ registerProtocol(t, h.c, f)
374+ requestProtocol(t, h.c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, h.surf)
375+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestSetBarrier)
376+ h.commit(dead, 3, 4)
377+ requestProtocol(t, h.c, dead, wayland.BufferRequestDestroy)
378+ if err := h.c.Roundtrip(); err != nil {
379+ t.Fatal(err)
380+ }
381+ // The dead attachment is skipped; it does not replace the current one.
382+ // A later attachment is queued while the old fence remains unsignalled.
383+ h.commit(next, 5, 6)
384+ h.fire(5)
385+ got, ok := h.content(2 * time.Second)
386+ if !ok || got.DMABuf == nil {
387+ t.Fatalf("later content blocked: %+v", got)
388+ }
389+ for _, p := range h.signalled() {
390+ if p == 4 {
391+ t.Fatal("skipped buffer release signalled before acquire")
392+ }
393+ }
394+}
395+
358 func TestSyncobjProtocolErrors(t *testing.T) {396 func TestSyncobjProtocolErrors(t *testing.T) {
359 for _, tc := range []struct {397 for _, tc := range []struct {
360 name string398 name string
A internal/adapters/wayland/syncwait_test.go +127 -0
@@ -0,0 +1,127 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "context"
6+ "math"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+func TestSyncWaitAlreadySignalled(t *testing.T) {
16+ dev := newMocksyncobjDevice(t)
17+ dev.EXPECT().signalled(uint32(7), uint64(4)).Return(true, nil).Once()
18+ sw, err := newSyncWaiter(func() {})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ defer sw.pipeR.Close()
23+ defer sw.close()
24+ w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4})
25+ if err != nil || !sw.fired(w) {
26+ t.Fatalf("wait: %v, ready: %v", err, sw.fired(w))
27+ }
28+}
29+
30+// An invalid poll result is not readiness; it must not hold the surface.
31+func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {
32+ sw, err := newSyncWaiter(func() {})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer sw.pipeR.Close()
37+ defer sw.close()
38+ var output bytes.Buffer
39+ sw.log = zerowrap.New(zerowrap.Config{Output: &output})
40+ // An fd number no process can own polls as POLLNVAL; its finalizer
41+ // cannot close a reused descriptor.
42+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}}
43+ var waits [4]*syncWait
44+ for range 2 {
45+ if err := sw.watchImplicit(b, &waits); err != nil || waits[0] != nil {
46+ t.Fatalf("poll error should not register wait: %v %+v", err, waits)
47+ }
48+ }
49+ if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 {
50+ t.Fatalf("poll warnings %d: %s", n, output.String())
51+ }
52+}
53+
54+// POLLHUP with no POLLIN on a watched fd must wake the surface too.
55+func TestImplicitPollHangupWakes(t *testing.T) {
56+ sw, err := newSyncWaiter(func() {})
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ctx, cancel := context.WithCancel(context.Background())
61+ done := make(chan struct{})
62+ go func() { sw.run(ctx); close(done) }()
63+ defer func() { cancel(); sw.close(); <-done }()
64+ r, wr, err := os.Pipe()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ defer r.Close()
69+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: r}}}
70+ var waits [4]*syncWait
71+ if err := sw.watchImplicit(b, &waits); err != nil || waits[0] == nil {
72+ t.Fatalf("watch: %v", err)
73+ }
74+ wr.Close()
75+ deadline := time.Now().Add(time.Second)
76+ for !sw.fired(waits[0]) && time.Now().Before(deadline) {
77+ time.Sleep(time.Millisecond)
78+ }
79+ if !sw.fired(waits[0]) {
80+ t.Fatal("hangup never woke waiter")
81+ }
82+}
83+
84+func TestImplicitWaitAndDrop(t *testing.T) {
85+ sw, err := newSyncWaiter(func() {})
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ ctx, cancel := context.WithCancel(context.Background())
90+ done := make(chan struct{})
91+ go func() { sw.run(ctx); close(done) }()
92+ defer func() { cancel(); sw.close(); <-done }()
93+ r, wr, err := os.Pipe()
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ defer r.Close()
98+ defer wr.Close()
99+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: r}}}
100+ var waits [4]*syncWait
101+ err = sw.watchImplicit(b, &waits)
102+ if err != nil || waits[0] == nil || sw.fired(waits[0]) {
103+ t.Fatalf("not ready: %v", err)
104+ }
105+ sw.cancel(waits[0])
106+ waits = [4]*syncWait{}
107+ err = sw.watchImplicit(b, &waits)
108+ if err != nil || waits[0] == nil || sw.fired(waits[0]) {
109+ t.Fatalf("not ready: %v", err)
110+ }
111+ if _, err := wr.Write([]byte{1}); err != nil {
112+ t.Fatal(err)
113+ }
114+ deadline := time.Now().Add(time.Second)
115+ for !sw.fired(waits[0]) && time.Now().Before(deadline) {
116+ time.Sleep(time.Millisecond)
117+ }
118+ if !sw.fired(waits[0]) {
119+ t.Fatal("readable buffer never became ready")
120+ }
121+ // A newly attached readable buffer is ready without a poll goroutine hop.
122+ waits = [4]*syncWait{}
123+ err = sw.watchImplicit(b, &waits)
124+ if err != nil || waits[0] != nil {
125+ t.Fatalf("ready: %v, waits: %+v", err, waits)
126+ }
127+}
@@ -0,0 +1,127 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "context"
6+ "math"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+func TestSyncWaitAlreadySignalled(t *testing.T) {
16+ dev := newMocksyncobjDevice(t)
17+ dev.EXPECT().signalled(uint32(7), uint64(4)).Return(true, nil).Once()
18+ sw, err := newSyncWaiter(func() {})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ defer sw.pipeR.Close()
23+ defer sw.close()
24+ w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4})
25+ if err != nil || !sw.fired(w) {
26+ t.Fatalf("wait: %v, ready: %v", err, sw.fired(w))
27+ }
28+}
29+
30+// An invalid poll result is not readiness; it must not hold the surface.
31+func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {
32+ sw, err := newSyncWaiter(func() {})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer sw.pipeR.Close()
37+ defer sw.close()
38+ var output bytes.Buffer
39+ sw.log = zerowrap.New(zerowrap.Config{Output: &output})
40+ // An fd number no process can own polls as POLLNVAL; its finalizer
41+ // cannot close a reused descriptor.
42+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}}
43+ var waits [4]*syncWait
44+ for range 2 {
45+ if err := sw.watchImplicit(b, &waits); err != nil || waits[0] != nil {
46+ t.Fatalf("poll error should not register wait: %v %+v", err, waits)
47+ }
48+ }
49+ if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 {
50+ t.Fatalf("poll warnings %d: %s", n, output.String())
51+ }
52+}
53+
54+// POLLHUP with no POLLIN on a watched fd must wake the surface too.
55+func TestImplicitPollHangupWakes(t *testing.T) {
56+ sw, err := newSyncWaiter(func() {})
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ctx, cancel := context.WithCancel(context.Background())
61+ done := make(chan struct{})
62+ go func() { sw.run(ctx); close(done) }()
63+ defer func() { cancel(); sw.close(); <-done }()
64+ r, wr, err := os.Pipe()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ defer r.Close()
69+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: r}}}
70+ var waits [4]*syncWait
71+ if err := sw.watchImplicit(b, &waits); err != nil || waits[0] == nil {
72+ t.Fatalf("watch: %v", err)
73+ }
74+ wr.Close()
75+ deadline := time.Now().Add(time.Second)
76+ for !sw.fired(waits[0]) && time.Now().Before(deadline) {
77+ time.Sleep(time.Millisecond)
78+ }
79+ if !sw.fired(waits[0]) {
80+ t.Fatal("hangup never woke waiter")
81+ }
82+}
83+
84+func TestImplicitWaitAndDrop(t *testing.T) {
85+ sw, err := newSyncWaiter(func() {})
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ ctx, cancel := context.WithCancel(context.Background())
90+ done := make(chan struct{})
91+ go func() { sw.run(ctx); close(done) }()
92+ defer func() { cancel(); sw.close(); <-done }()
93+ r, wr, err := os.Pipe()
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ defer r.Close()
98+ defer wr.Close()
99+ b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: r}}}
100+ var waits [4]*syncWait
101+ err = sw.watchImplicit(b, &waits)
102+ if err != nil || waits[0] == nil || sw.fired(waits[0]) {
103+ t.Fatalf("not ready: %v", err)
104+ }
105+ sw.cancel(waits[0])
106+ waits = [4]*syncWait{}
107+ err = sw.watchImplicit(b, &waits)
108+ if err != nil || waits[0] == nil || sw.fired(waits[0]) {
109+ t.Fatalf("not ready: %v", err)
110+ }
111+ if _, err := wr.Write([]byte{1}); err != nil {
112+ t.Fatal(err)
113+ }
114+ deadline := time.Now().Add(time.Second)
115+ for !sw.fired(waits[0]) && time.Now().Before(deadline) {
116+ time.Sleep(time.Millisecond)
117+ }
118+ if !sw.fired(waits[0]) {
119+ t.Fatal("readable buffer never became ready")
120+ }
121+ // A newly attached readable buffer is ready without a poll goroutine hop.
122+ waits = [4]*syncWait{}
123+ err = sw.watchImplicit(b, &waits)
124+ if err != nil || waits[0] != nil {
125+ t.Fatalf("ready: %v, waits: %+v", err, waits)
126+ }
127+}
M internal/app/app.go +11 -0
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "os"
11+ "runtime"
1112 "sync"
1213 "time"
1314
@@ -17,6 +18,7 @@ import (
1718 "github.com/bnema/neferwl/internal/adapters/headlessinput"
1819 "github.com/bnema/neferwl/internal/adapters/launcher"
1920 "github.com/bnema/neferwl/internal/adapters/libinput"
21+ "github.com/bnema/neferwl/internal/adapters/sched"
2022 "github.com/bnema/neferwl/internal/adapters/statefile"
2123 "github.com/bnema/neferwl/internal/adapters/vulkan"
2224 "github.com/bnema/neferwl/internal/adapters/wayland"
@@ -189,6 +191,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
189191 return
190192 }
191193 done <- safe("input", func() error {
194+ if opts.Config.Performance.Realtime {
195+ runtime.LockOSThread()
196+ // Let this dedicated thread exit with its scheduling policy.
197+ if err := sched.Realtime(logging.For(ctx, "sched")); err != nil {
198+ log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
199+ }
200+ }
192201 return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
193202 })
194203 return
@@ -240,11 +249,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
240249 defer workers.Done()
241250 outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
242251 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
252+ renderLog := logging.For(ctx, "render")
243253 newRenderer := func(w, h int) (ports.Renderer, error) {
244254 r, err := vulkan.New(w, h)
245255 if err != nil {
246256 return nil, err
247257 }
258+ renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
248259 return r, nil
249260 }
250261 if hw != nil {
@@ -8,6 +8,7 @@ import (
8 "fmt"8 "fmt"
9 "io"9 "io"
10 "os"10 "os"
11+ "runtime"
11 "sync"12 "sync"
12 "time"13 "time"
13 14
@@ -17,6 +18,7 @@ import (
17 "github.com/bnema/neferwl/internal/adapters/headlessinput"18 "github.com/bnema/neferwl/internal/adapters/headlessinput"
18 "github.com/bnema/neferwl/internal/adapters/launcher"19 "github.com/bnema/neferwl/internal/adapters/launcher"
19 "github.com/bnema/neferwl/internal/adapters/libinput"20 "github.com/bnema/neferwl/internal/adapters/libinput"
21+ "github.com/bnema/neferwl/internal/adapters/sched"
20 "github.com/bnema/neferwl/internal/adapters/statefile"22 "github.com/bnema/neferwl/internal/adapters/statefile"
21 "github.com/bnema/neferwl/internal/adapters/vulkan"23 "github.com/bnema/neferwl/internal/adapters/vulkan"
22 "github.com/bnema/neferwl/internal/adapters/wayland"24 "github.com/bnema/neferwl/internal/adapters/wayland"
@@ -189,6 +191,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
189 return191 return
190 }192 }
191 done <- safe("input", func() error {193 done <- safe("input", func() error {
194+ if opts.Config.Performance.Realtime {
195+ runtime.LockOSThread()
196+ // Let this dedicated thread exit with its scheduling policy.
197+ if err := sched.Realtime(logging.For(ctx, "sched")); err != nil {
198+ log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
199+ }
200+ }
192 return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)201 return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
193 })202 })
194 return203 return
@@ -240,11 +249,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
240 defer workers.Done()249 defer workers.Done()
241 outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}250 outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
242 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))251 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
252+ renderLog := logging.For(ctx, "render")
243 newRenderer := func(w, h int) (ports.Renderer, error) {253 newRenderer := func(w, h int) (ports.Renderer, error) {
244 r, err := vulkan.New(w, h)254 r, err := vulkan.New(w, h)
245 if err != nil {255 if err != nil {
246 return nil, err256 return nil, err
247 }257 }
258+ renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
248 return r, nil259 return r, nil
249 }260 }
250 if hw != nil {261 if hw != nil {
M internal/app/drm.go +11 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "path/filepath"
8+ "runtime"
89 "runtime/debug"
910 "sort"
1011 "sync"
@@ -13,6 +14,7 @@ import (
1314 "github.com/bnema/neferwl/internal/adapters/clock"
1415 "github.com/bnema/neferwl/internal/adapters/config"
1516 "github.com/bnema/neferwl/internal/adapters/drm"
17+ "github.com/bnema/neferwl/internal/adapters/sched"
1618 "github.com/bnema/neferwl/internal/adapters/seat"
1719 "github.com/bnema/neferwl/internal/logging"
1820 "github.com/bnema/neferwl/internal/ports"
@@ -274,10 +276,19 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
274276 curs.set(name, cur)
275277 }
276278 active := b.seat.Subscribe()
279+ realtime := currentConfig.Performance.Realtime
277280 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
278281 defer b.seat.Unsubscribe(active)
279282 return safe("output "+name, func() error {
280283 defer o.Close()
284+ if realtime {
285+ runtime.LockOSThread()
286+ // Keep the thread locked until this goroutine exits: an RT
287+ // thread must not return to Go's general-purpose pool.
288+ if err := sched.Realtime(log); err != nil {
289+ log.Warn().Str("component", "sched").Err(err).Msg("output scheduling")
290+ }
291+ }
281292 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
282293 })
283294 })
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "path/filepath"7 "path/filepath"
8+ "runtime"
8 "runtime/debug"9 "runtime/debug"
9 "sort"10 "sort"
10 "sync"11 "sync"
@@ -13,6 +14,7 @@ import (
13 "github.com/bnema/neferwl/internal/adapters/clock"14 "github.com/bnema/neferwl/internal/adapters/clock"
14 "github.com/bnema/neferwl/internal/adapters/config"15 "github.com/bnema/neferwl/internal/adapters/config"
15 "github.com/bnema/neferwl/internal/adapters/drm"16 "github.com/bnema/neferwl/internal/adapters/drm"
17+ "github.com/bnema/neferwl/internal/adapters/sched"
16 "github.com/bnema/neferwl/internal/adapters/seat"18 "github.com/bnema/neferwl/internal/adapters/seat"
17 "github.com/bnema/neferwl/internal/logging"19 "github.com/bnema/neferwl/internal/logging"
18 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
@@ -274,10 +276,19 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
274 curs.set(name, cur)276 curs.set(name, cur)
275 }277 }
276 active := b.seat.Subscribe()278 active := b.seat.Subscribe()
279+ realtime := currentConfig.Performance.Realtime
277 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {280 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
278 defer b.seat.Unsubscribe(active)281 defer b.seat.Unsubscribe(active)
279 return safe("output "+name, func() error {282 return safe("output "+name, func() error {
280 defer o.Close()283 defer o.Close()
284+ if realtime {
285+ runtime.LockOSThread()
286+ // Keep the thread locked until this goroutine exits: an RT
287+ // thread must not return to Go's general-purpose pool.
288+ if err := sched.Realtime(log); err != nil {
289+ log.Warn().Str("component", "sched").Err(err).Msg("output scheduling")
290+ }
291+ }
281 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)292 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
282 })293 })
283 })294 })
M internal/ports/config.go +2 -1
@@ -114,7 +114,8 @@ type Config struct {
114114 // turns variable refresh on while a buffer is scanned out.
115115 Tearing, VRR bool
116116 }
117- Log struct {
117+ Performance struct{ Realtime bool }
118+ Log struct {
118119 Level string
119120 Debug []string
120121 }
@@ -114,7 +114,8 @@ type Config struct {
114 // turns variable refresh on while a buffer is scanned out.114 // turns variable refresh on while a buffer is scanned out.
115 Tearing, VRR bool115 Tearing, VRR bool
116 }116 }
117- Log struct {117+ Performance struct{ Realtime bool }
118+ Log struct {
118 Level string119 Level string
119 Debug []string120 Debug []string
120 }121 }
M internal/ports/messages.go +12 -1
@@ -845,6 +845,16 @@ func (c SurfaceColor) IsExtendedLinear() bool {
845845 return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
846846 }
847847
848+// ContentType is the committed wp_content_type_v1 hint.
849+type ContentType uint8
850+
851+const (
852+ ContentNone ContentType = iota
853+ ContentPhoto
854+ ContentVideo
855+ ContentGame
856+)
857+
848858 // SurfaceContent carries wayland → output the latest committed content of a
849859 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
850860 // Renderers read both in place and never modify them. Empty means the window
@@ -853,7 +863,8 @@ func (c SurfaceColor) IsExtendedLinear() bool {
853863 // Geometry is the part of the surface that is the window (xdg window
854864 // geometry), the rest being client shadows.
855865 type SurfaceContent struct {
856- ID WindowID
866+ ID WindowID
867+ ContentType ContentType
857868 // Surface is a never-reused wl_surface identity; Version identifies its
858869 // applied buffer content independently of the window publication Seq.
859870 Surface, Version uint64
@@ -845,6 +845,16 @@ func (c SurfaceColor) IsExtendedLinear() bool {
845 return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB845 return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
846 }846 }
847 847
848+// ContentType is the committed wp_content_type_v1 hint.
849+type ContentType uint8
850+
851+const (
852+ ContentNone ContentType = iota
853+ ContentPhoto
854+ ContentVideo
855+ ContentGame
856+)
857+
848 // SurfaceContent carries wayland → output the latest committed content of a858 // SurfaceContent carries wayland → output the latest committed content of a
849 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.859 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
850 // Renderers read both in place and never modify them. Empty means the window860 // Renderers read both in place and never modify them. Empty means the window
@@ -853,7 +863,8 @@ func (c SurfaceColor) IsExtendedLinear() bool {
853 // Geometry is the part of the surface that is the window (xdg window863 // Geometry is the part of the surface that is the window (xdg window
854 // geometry), the rest being client shadows.864 // geometry), the rest being client shadows.
855 type SurfaceContent struct {865 type SurfaceContent struct {
856- ID WindowID866+ ID WindowID
867+ ContentType ContentType
857 // Surface is a never-reused wl_surface identity; Version identifies its868 // Surface is a never-reused wl_surface identity; Version identifies its
858 // applied buffer content independently of the window publication Seq.869 // applied buffer content independently of the window publication Seq.
859 Surface, Version uint64870 Surface, Version uint64

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close