perf(wayland): cache shm buffer content and keep frame callback queues
buffer.content built a new SHMBuffer and an os.FileInfo on every commit: the SHMBuffer is now built once per buffer and the size is still checked every commit with unix.Fstat on the stack. dueFrames reuses a fire scratch, and sendFrames keeps the emptied queue's backing array instead of deleting it. Queued commit: 18 down to 16 allocs/op.
- commit
- 57b180563c6a57455a9a199561d8cb07166bec8a
- author
- brice · · 2026-10-16 14:04 UTC
- parent
- fea658c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +118 -7
M internal/adapters/wayland/alloc_hotpath_test.go +86 -0
| @@ -1,11 +1,15 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "slices" | |
| 4 | 6 | "testing" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 7 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| 11 | 15 | // The steady-state hot paths that run for every frame or commit allocate | |
| @@ -125,3 +129,85 @@ func TestEmitContentAllocations(t *testing.T) { | |||
| 125 | 129 | } | |
| 126 | 130 | t.Logf("emitContent: %.2f allocs", allocs) | |
| 127 | 131 | } | |
| 132 | + | ||
| 133 | + | // Firing an output's callbacks keeps its queue's backing array, so the next | |
| 134 | + | // commit's queueFrames appends in place (before: the queue was deleted by | |
| 135 | + | // every fire and each commit grew a fresh slice). An emptied queue reads | |
| 136 | + | // like an absent one, and pins no fired callback. | |
| 137 | + | func TestFrameQueueKeepsBackingArray(t *testing.T) { | |
| 138 | + | s, events, commands, _, _, dir := presentServerOn(t, testOutputs) | |
| 139 | + | c := protocolClient(t, s, dir) | |
| 140 | + | w, surf, _ := surfaceMapper(t, c, events)() | |
| 141 | + | placeWindow(t, s, commands, w, "HEADLESS-1") | |
| 142 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |
| 143 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |
| 144 | + | var first *wayland.Callback | |
| 145 | + | var head **wayland.Callback | |
| 146 | + | s.display.Do(func() { | |
| 147 | + | // The pacer fired the queue during the awaitFrame calls above. | |
| 148 | + | queue := s.awaiting["HEADLESS-1"] | |
| 149 | + | if len(queue) != 0 || cap(queue) == 0 { | |
| 150 | + | t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue)) | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | first, head = queue[:1][0], &queue[:1][0] | |
| 154 | + | if len(s.frameOwners) != 0 { | |
| 155 | + | t.Errorf("fired queue keeps %d frame owners", len(s.frameOwners)) | |
| 156 | + | } | |
| 157 | + | }) | |
| 158 | + | if first != nil { | |
| 159 | + | t.Fatal("fired queue pins a callback") | |
| 160 | + | } | |
| 161 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |
| 162 | + | s.display.Do(func() { | |
| 163 | + | if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head { | |
| 164 | + | t.Error("queue reallocated between frames") | |
| 165 | + | } | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // dueFrames hands out one scratch slice for every tick. | |
| 170 | + | func TestDueFramesFireScratch(t *testing.T) { | |
| 171 | + | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 172 | + | s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}} | |
| 173 | + | now := time.Unix(100, 0) | |
| 174 | + | flipped := map[string]bool{"DP-2": true} | |
| 175 | + | fire, _, _ := s.dueFrames(now, flipped) | |
| 176 | + | if !slices.Equal(fire, []string{"DP-2"}) { | |
| 177 | + | t.Fatalf("fire %v", fire) | |
| 178 | + | } | |
| 179 | + | if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 { | |
| 180 | + | t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // A buffer sends the same SHMBuffer on every commit and still checks the | |
| 185 | + | // file's size each time: a client may truncate it. | |
| 186 | + | func TestBufferContentAllocations(t *testing.T) { | |
| 187 | + | fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | f := os.NewFile(uintptr(fd), "content-allocs") | |
| 192 | + | defer f.Close() | |
| 193 | + | if err := f.Truncate(28); err != nil { | |
| 194 | + | t.Fatal(err) | |
| 195 | + | } | |
| 196 | + | b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16} | |
| 197 | + | first, ok := b.content(1) | |
| 198 | + | if !ok { | |
| 199 | + | t.Fatal("no content") | |
| 200 | + | } | |
| 201 | + | if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 { | |
| 202 | + | t.Errorf("buffer.content: %.1f allocs, want 0", allocs) | |
| 203 | + | } | |
| 204 | + | if again, _ := b.content(2); again.SHM != first.SHM { | |
| 205 | + | t.Fatal("SHMBuffer rebuilt per commit") | |
| 206 | + | } | |
| 207 | + | if err := f.Truncate(27); err != nil { | |
| 208 | + | t.Fatal(err) | |
| 209 | + | } | |
| 210 | + | if _, ok := b.content(1); ok { | |
| 211 | + | t.Fatal("content from a truncated pool") | |
| 212 | + | } | |
| 213 | + | } | |
| @@ -1,11 +1,15 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "slices" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | "time" | 7 | "time" | ||
| 6 | 8 | ||||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |||
| 7 | "github.com/bnema/neferwl/internal/ports" | 10 | "github.com/bnema/neferwl/internal/ports" | ||
| 8 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 12 | + | "golang.org/x/sys/unix" | |||
| 9 | ) | 13 | ) | ||
| 10 | 14 | ||||
| 11 | // The steady-state hot paths that run for every frame or commit allocate | 15 | // The steady-state hot paths that run for every frame or commit allocate | ||
| @@ -125,3 +129,85 @@ func TestEmitContentAllocations(t *testing.T) { | |||||
| 125 | } | 129 | } | ||
| 126 | t.Logf("emitContent: %.2f allocs", allocs) | 130 | t.Logf("emitContent: %.2f allocs", allocs) | ||
| 127 | } | 131 | } | ||
| 132 | + | ||||
| 133 | + | // Firing an output's callbacks keeps its queue's backing array, so the next | |||
| 134 | + | // commit's queueFrames appends in place (before: the queue was deleted by | |||
| 135 | + | // every fire and each commit grew a fresh slice). An emptied queue reads | |||
| 136 | + | // like an absent one, and pins no fired callback. | |||
| 137 | + | func TestFrameQueueKeepsBackingArray(t *testing.T) { | |||
| 138 | + | s, events, commands, _, _, dir := presentServerOn(t, testOutputs) | |||
| 139 | + | c := protocolClient(t, s, dir) | |||
| 140 | + | w, surf, _ := surfaceMapper(t, c, events)() | |||
| 141 | + | placeWindow(t, s, commands, w, "HEADLESS-1") | |||
| 142 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |||
| 143 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |||
| 144 | + | var first *wayland.Callback | |||
| 145 | + | var head **wayland.Callback | |||
| 146 | + | s.display.Do(func() { | |||
| 147 | + | // The pacer fired the queue during the awaitFrame calls above. | |||
| 148 | + | queue := s.awaiting["HEADLESS-1"] | |||
| 149 | + | if len(queue) != 0 || cap(queue) == 0 { | |||
| 150 | + | t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue)) | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | first, head = queue[:1][0], &queue[:1][0] | |||
| 154 | + | if len(s.frameOwners) != 0 { | |||
| 155 | + | t.Errorf("fired queue keeps %d frame owners", len(s.frameOwners)) | |||
| 156 | + | } | |||
| 157 | + | }) | |||
| 158 | + | if first != nil { | |||
| 159 | + | t.Fatal("fired queue pins a callback") | |||
| 160 | + | } | |||
| 161 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |||
| 162 | + | s.display.Do(func() { | |||
| 163 | + | if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head { | |||
| 164 | + | t.Error("queue reallocated between frames") | |||
| 165 | + | } | |||
| 166 | + | }) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // dueFrames hands out one scratch slice for every tick. | |||
| 170 | + | func TestDueFramesFireScratch(t *testing.T) { | |||
| 171 | + | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |||
| 172 | + | s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}} | |||
| 173 | + | now := time.Unix(100, 0) | |||
| 174 | + | flipped := map[string]bool{"DP-2": true} | |||
| 175 | + | fire, _, _ := s.dueFrames(now, flipped) | |||
| 176 | + | if !slices.Equal(fire, []string{"DP-2"}) { | |||
| 177 | + | t.Fatalf("fire %v", fire) | |||
| 178 | + | } | |||
| 179 | + | if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 { | |||
| 180 | + | t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // A buffer sends the same SHMBuffer on every commit and still checks the | |||
| 185 | + | // file's size each time: a client may truncate it. | |||
| 186 | + | func TestBufferContentAllocations(t *testing.T) { | |||
| 187 | + | fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC) | |||
| 188 | + | if err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | f := os.NewFile(uintptr(fd), "content-allocs") | |||
| 192 | + | defer f.Close() | |||
| 193 | + | if err := f.Truncate(28); err != nil { | |||
| 194 | + | t.Fatal(err) | |||
| 195 | + | } | |||
| 196 | + | b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16} | |||
| 197 | + | first, ok := b.content(1) | |||
| 198 | + | if !ok { | |||
| 199 | + | t.Fatal("no content") | |||
| 200 | + | } | |||
| 201 | + | if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 { | |||
| 202 | + | t.Errorf("buffer.content: %.1f allocs, want 0", allocs) | |||
| 203 | + | } | |||
| 204 | + | if again, _ := b.content(2); again.SHM != first.SHM { | |||
| 205 | + | t.Fatal("SHMBuffer rebuilt per commit") | |||
| 206 | + | } | |||
| 207 | + | if err := f.Truncate(27); err != nil { | |||
| 208 | + | t.Fatal(err) | |||
| 209 | + | } | |||
| 210 | + | if _, ok := b.content(1); ok { | |||
| 211 | + | t.Fatal("content from a truncated pool") | |||
| 212 | + | } | |||
| 213 | + | } | |||
M internal/adapters/wayland/frames.go +14 -3
| @@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) { | |||
| 253 | 253 | dest := s.frameOutput(owner) | |
| 254 | 254 | moved[dest] = append(moved[dest], cb) | |
| 255 | 255 | } | |
| 256 | + | clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks | |
| 256 | 257 | if len(kept) == 0 { | |
| 257 | 258 | delete(s.awaiting, name) | |
| 258 | 259 | } else { | |
| @@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) { | |||
| 270 | 271 | ||
| 271 | 272 | // dueFrames returns the queues to fire now: flipped outputs and those past | |
| 272 | 273 | // their deadline. wait is the time to the next deadline; idle is set when | |
| 273 | - | // no callback waits after firing. | |
| 274 | + | // no callback waits after firing. fire is the tick's scratch, valid until | |
| 275 | + | // the next call (display goroutine only). | |
| 274 | 276 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | |
| 277 | + | fire = s.frameFire[:0] | |
| 275 | 278 | if s.framePeriods == nil { | |
| 276 | 279 | s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1) | |
| 277 | 280 | } | |
| @@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||
| 284 | 287 | for name, callbacks := range s.awaiting { | |
| 285 | 288 | if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue { | |
| 286 | 289 | delete(s.awaiting, name) | |
| 287 | - | s.awaiting[""] = append(s.awaiting[""], callbacks...) | |
| 290 | + | if len(callbacks) > 0 { | |
| 291 | + | s.awaiting[""] = append(s.awaiting[""], callbacks...) | |
| 292 | + | } | |
| 288 | 293 | } | |
| 289 | 294 | } | |
| 290 | 295 | periods[""] = defaultFramePeriod | |
| @@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||
| 330 | 335 | wait = 0 | |
| 331 | 336 | } | |
| 332 | 337 | } | |
| 338 | + | s.frameFire = fire | |
| 333 | 339 | return fire, max(wait, time.Millisecond), !waiting | |
| 334 | 340 | } | |
| 335 | 341 | ||
| @@ -341,10 +347,15 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa | |||
| 341 | 347 | // sendFrames fires an output's callbacks; flipped: a flip of it fired them. | |
| 342 | 348 | func (s *Server) sendFrames(name string, flipped bool) { | |
| 343 | 349 | callbacks := s.awaiting[name] | |
| 344 | - | delete(s.awaiting, name) | |
| 345 | 350 | if len(callbacks) == 0 { | |
| 346 | 351 | return | |
| 347 | 352 | } | |
| 353 | + | // The queue keeps its backing array for the next frame (an empty queue | |
| 354 | + | // reads like an absent one). Nothing below queues a callback. | |
| 355 | + | defer func() { | |
| 356 | + | clear(callbacks) | |
| 357 | + | s.awaiting[name] = callbacks[:0] | |
| 358 | + | }() | |
| 348 | 359 | // done carries the flip's time when the output flips (the frame the | |
| 349 | 360 | // client drew for is on screen), else now. | |
| 350 | 361 | ms := uint32(time.Since(s.started).Milliseconds()) | |
| @@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) { | |||||
| 253 | dest := s.frameOutput(owner) | 253 | dest := s.frameOutput(owner) | ||
| 254 | moved[dest] = append(moved[dest], cb) | 254 | moved[dest] = append(moved[dest], cb) | ||
| 255 | } | 255 | } | ||
| 256 | + | clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks | |||
| 256 | if len(kept) == 0 { | 257 | if len(kept) == 0 { | ||
| 257 | delete(s.awaiting, name) | 258 | delete(s.awaiting, name) | ||
| 258 | } else { | 259 | } else { | ||
| @@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) { | |||||
| 270 | 271 | ||||
| 271 | // dueFrames returns the queues to fire now: flipped outputs and those past | 272 | // dueFrames returns the queues to fire now: flipped outputs and those past | ||
| 272 | // their deadline. wait is the time to the next deadline; idle is set when | 273 | // their deadline. wait is the time to the next deadline; idle is set when | ||
| 273 | - | // no callback waits after firing. | 274 | + | // no callback waits after firing. fire is the tick's scratch, valid until |
| 275 | + | // the next call (display goroutine only). | |||
| 274 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | 276 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | ||
| 277 | + | fire = s.frameFire[:0] | |||
| 275 | if s.framePeriods == nil { | 278 | if s.framePeriods == nil { | ||
| 276 | s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1) | 279 | s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1) | ||
| 277 | } | 280 | } | ||
| @@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||||
| 284 | for name, callbacks := range s.awaiting { | 287 | for name, callbacks := range s.awaiting { | ||
| 285 | if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue { | 288 | if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue { | ||
| 286 | delete(s.awaiting, name) | 289 | delete(s.awaiting, name) | ||
| 287 | - | s.awaiting[""] = append(s.awaiting[""], callbacks...) | 290 | + | if len(callbacks) > 0 { |
| 291 | + | s.awaiting[""] = append(s.awaiting[""], callbacks...) | |||
| 292 | + | } | |||
| 288 | } | 293 | } | ||
| 289 | } | 294 | } | ||
| 290 | periods[""] = defaultFramePeriod | 295 | periods[""] = defaultFramePeriod | ||
| @@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||||
| 330 | wait = 0 | 335 | wait = 0 | ||
| 331 | } | 336 | } | ||
| 332 | } | 337 | } | ||
| 338 | + | s.frameFire = fire | |||
| 333 | return fire, max(wait, time.Millisecond), !waiting | 339 | return fire, max(wait, time.Millisecond), !waiting | ||
| 334 | } | 340 | } | ||
| 335 | 341 | ||||
| @@ -341,10 +347,15 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa | |||||
| 341 | // sendFrames fires an output's callbacks; flipped: a flip of it fired them. | 347 | // sendFrames fires an output's callbacks; flipped: a flip of it fired them. | ||
| 342 | func (s *Server) sendFrames(name string, flipped bool) { | 348 | func (s *Server) sendFrames(name string, flipped bool) { | ||
| 343 | callbacks := s.awaiting[name] | 349 | callbacks := s.awaiting[name] | ||
| 344 | - | delete(s.awaiting, name) | |||
| 345 | if len(callbacks) == 0 { | 350 | if len(callbacks) == 0 { | ||
| 346 | return | 351 | return | ||
| 347 | } | 352 | } | ||
| 353 | + | // The queue keeps its backing array for the next frame (an empty queue | |||
| 354 | + | // reads like an absent one). Nothing below queues a callback. | |||
| 355 | + | defer func() { | |||
| 356 | + | clear(callbacks) | |||
| 357 | + | s.awaiting[name] = callbacks[:0] | |||
| 358 | + | }() | |||
| 348 | // done carries the flip's time when the output flips (the frame the | 359 | // done carries the flip's time when the output flips (the frame the | ||
| 349 | // client drew for is on screen), else now. | 360 | // client drew for is on screen), else now. | ||
| 350 | ms := uint32(time.Since(s.started).Milliseconds()) | 361 | ms := uint32(time.Since(s.started).Milliseconds()) | ||
M internal/adapters/wayland/globals.go +16 -4
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func registerGlobals(d *server.Display, o Options, s *Server) error { | |
| @@ -389,6 +390,12 @@ type buffer struct { | |||
| 389 | 390 | pool *pool | |
| 390 | 391 | offset, width, height, stride int | |
| 391 | 392 | format uint32 | |
| 393 | + | // shm and fd are built on the first content call. The pool, offset and | |
| 394 | + | // stride never change, so every commit sends the same read-only | |
| 395 | + | // SHMBuffer. The pool keeps the file open while the buffer lives, which | |
| 396 | + | // keeps fd valid. | |
| 397 | + | shm *ports.SHMBuffer | |
| 398 | + | fd int | |
| 392 | 399 | } | |
| 393 | 400 | ||
| 394 | 401 | func (*buffer) Destroy(*wayland.Buffer) {} | |
| @@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) { | |||
| 417 | 424 | } | |
| 418 | 425 | ||
| 419 | 426 | // content points the renderer at the pool; a file shrunk below the buffer | |
| 420 | - | // is refused. | |
| 427 | + | // is refused. The size is checked on every call: a client may truncate the | |
| 428 | + | // file at any time, so it is never cached. | |
| 421 | 429 | func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) { | |
| 422 | - | if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) { | |
| 430 | + | if b.shm == nil { | |
| 431 | + | b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride} | |
| 432 | + | b.fd = int(b.pool.file.Fd()) | |
| 433 | + | } | |
| 434 | + | var st unix.Stat_t | |
| 435 | + | if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) { | |
| 423 | 436 | return ports.SurfaceContent{}, false | |
| 424 | 437 | } | |
| 425 | - | shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride} | |
| 426 | - | return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true | |
| 438 | + | return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true | |
| 427 | 439 | } | |
| 428 | 440 | ||
| 429 | 441 | type seat struct{ server *Server } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/bnema/go-wayland-bindings/server/wayland" | 7 | "github.com/bnema/go-wayland-bindings/server/wayland" | ||
| 8 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| 9 | "github.com/bnema/purego-libwayland/server" | 9 | "github.com/bnema/purego-libwayland/server" | ||
| 10 | + | "golang.org/x/sys/unix" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | func registerGlobals(d *server.Display, o Options, s *Server) error { | 13 | func registerGlobals(d *server.Display, o Options, s *Server) error { | ||
| @@ -389,6 +390,12 @@ type buffer struct { | |||||
| 389 | pool *pool | 390 | pool *pool | ||
| 390 | offset, width, height, stride int | 391 | offset, width, height, stride int | ||
| 391 | format uint32 | 392 | format uint32 | ||
| 393 | + | // shm and fd are built on the first content call. The pool, offset and | |||
| 394 | + | // stride never change, so every commit sends the same read-only | |||
| 395 | + | // SHMBuffer. The pool keeps the file open while the buffer lives, which | |||
| 396 | + | // keeps fd valid. | |||
| 397 | + | shm *ports.SHMBuffer | |||
| 398 | + | fd int | |||
| 392 | } | 399 | } | ||
| 393 | 400 | ||||
| 394 | func (*buffer) Destroy(*wayland.Buffer) {} | 401 | func (*buffer) Destroy(*wayland.Buffer) {} | ||
| @@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) { | |||||
| 417 | } | 424 | } | ||
| 418 | 425 | ||||
| 419 | // content points the renderer at the pool; a file shrunk below the buffer | 426 | // content points the renderer at the pool; a file shrunk below the buffer | ||
| 420 | - | // is refused. | 427 | + | // is refused. The size is checked on every call: a client may truncate the |
| 428 | + | // file at any time, so it is never cached. | |||
| 421 | func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) { | 429 | func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) { | ||
| 422 | - | if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) { | 430 | + | if b.shm == nil { |
| 431 | + | b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride} | |||
| 432 | + | b.fd = int(b.pool.file.Fd()) | |||
| 433 | + | } | |||
| 434 | + | var st unix.Stat_t | |||
| 435 | + | if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) { | |||
| 423 | return ports.SurfaceContent{}, false | 436 | return ports.SurfaceContent{}, false | ||
| 424 | } | 437 | } | ||
| 425 | - | shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride} | 438 | + | return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true |
| 426 | - | return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true | |||
| 427 | } | 439 | } | ||
| 428 | 440 | ||||
| 429 | type seat struct{ server *Server } | 441 | type seat struct{ server *Server } | ||
M internal/adapters/wayland/server.go +2 -0
| @@ -189,6 +189,8 @@ type Server struct { | |||
| 189 | 189 | sink *presentSink | |
| 190 | 190 | // framePeriods is the tick's scratch (display goroutine only). | |
| 191 | 191 | framePeriods map[string]time.Duration | |
| 192 | + | // frameFire is dueFrames' result slice, reused across ticks. | |
| 193 | + | frameFire []string | |
| 192 | 194 | // held buffers wait until no output reads them (release.go); reports | |
| 193 | 195 | // are the outputs' latest ports.OutputPresented. | |
| 194 | 196 | held []heldBuffer | |
| @@ -189,6 +189,8 @@ type Server struct { | |||||
| 189 | sink *presentSink | 189 | sink *presentSink | ||
| 190 | // framePeriods is the tick's scratch (display goroutine only). | 190 | // framePeriods is the tick's scratch (display goroutine only). | ||
| 191 | framePeriods map[string]time.Duration | 191 | framePeriods map[string]time.Duration | ||
| 192 | + | // frameFire is dueFrames' result slice, reused across ticks. | |||
| 193 | + | frameFire []string | |||
| 192 | // held buffers wait until no output reads them (release.go); reports | 194 | // held buffers wait until no output reads them (release.go); reports | ||
| 193 | // are the outputs' latest ports.OutputPresented. | 195 | // are the outputs' latest ports.OutputPresented. | ||
| 194 | held []heldBuffer | 196 | held []heldBuffer | ||