fix(wayland): bind posted callbacks before the sync waiter and log pace timer failures
- commit
- 5f079f4ca2c8fef180a13f7b6b193789186da803
- author
- brice · · 2026-10-13 17:58 UTC
- committer
- brice · · 2026-10-14 04:34 UTC
- parent
- 50ff603
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +24 -20
M internal/adapters/wayland/frames.go +19 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "slices" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -47,20 +48,35 @@ func (s *Server) armPace(d time.Duration) { | |||
| 47 | 48 | d = time.Nanosecond | |
| 48 | 49 | } | |
| 49 | 50 | spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(d))} | |
| 50 | - | _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | |
| 51 | + | s.warnPace("arm", unix.TimerfdSettime(s.paceFD, 0, &spec, nil)) | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | 54 | // stopPace disarms the pace timerfd. Display goroutine only. | |
| 54 | 55 | func (s *Server) stopPace() { | |
| 55 | 56 | var spec unix.ItimerSpec | |
| 56 | - | _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | |
| 57 | + | s.warnPace("stop", unix.TimerfdSettime(s.paceFD, 0, &spec, nil)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // warnPace logs the first failure of a pace timerfd operation (later ones | |
| 61 | + | // would repeat at frame rate). A failed arm leaves frame callbacks waiting | |
| 62 | + | // for the next commit or flip. Display goroutine only. | |
| 63 | + | func (s *Server) warnPace(op string, err error) { | |
| 64 | + | if err == nil || s.paceWarned { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | s.paceWarned = true | |
| 68 | + | s.log.Warn().Err(err).Str("op", op).Msg("pace timer") | |
| 57 | 69 | } | |
| 58 | 70 | ||
| 59 | 71 | // onPaceTimer is the display loop's callback for the pace timerfd: the | |
| 60 | 72 | // deadline armed by the last tick passed. | |
| 61 | 73 | func (s *Server) onPaceTimer() { | |
| 62 | 74 | var buf [8]byte | |
| 63 | - | _, _ = unix.Read(s.paceFD, buf[:]) // level-triggered: consume the expiry | |
| 75 | + | // Level-triggered: consume the expiry. EAGAIN: the timer was disarmed | |
| 76 | + | // after the loop saw it readable. | |
| 77 | + | if _, err := unix.Read(s.paceFD, buf[:]); err != nil && !errors.Is(err, unix.EAGAIN) { | |
| 78 | + | s.warnPace("read", err) | |
| 79 | + | } | |
| 64 | 80 | s.paceTick(nil, nil) | |
| 65 | 81 | } | |
| 66 | 82 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "slices" | 5 | "slices" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -47,20 +48,35 @@ func (s *Server) armPace(d time.Duration) { | |||||
| 47 | d = time.Nanosecond | 48 | d = time.Nanosecond | ||
| 48 | } | 49 | } | ||
| 49 | spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(d))} | 50 | spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(d))} | ||
| 50 | - | _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | 51 | + | s.warnPace("arm", unix.TimerfdSettime(s.paceFD, 0, &spec, nil)) |
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | // stopPace disarms the pace timerfd. Display goroutine only. | 54 | // stopPace disarms the pace timerfd. Display goroutine only. | ||
| 54 | func (s *Server) stopPace() { | 55 | func (s *Server) stopPace() { | ||
| 55 | var spec unix.ItimerSpec | 56 | var spec unix.ItimerSpec | ||
| 56 | - | _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | 57 | + | s.warnPace("stop", unix.TimerfdSettime(s.paceFD, 0, &spec, nil)) |
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // warnPace logs the first failure of a pace timerfd operation (later ones | |||
| 61 | + | // would repeat at frame rate). A failed arm leaves frame callbacks waiting | |||
| 62 | + | // for the next commit or flip. Display goroutine only. | |||
| 63 | + | func (s *Server) warnPace(op string, err error) { | |||
| 64 | + | if err == nil || s.paceWarned { | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | s.paceWarned = true | |||
| 68 | + | s.log.Warn().Err(err).Str("op", op).Msg("pace timer") | |||
| 57 | } | 69 | } | ||
| 58 | 70 | ||||
| 59 | // onPaceTimer is the display loop's callback for the pace timerfd: the | 71 | // onPaceTimer is the display loop's callback for the pace timerfd: the | ||
| 60 | // deadline armed by the last tick passed. | 72 | // deadline armed by the last tick passed. | ||
| 61 | func (s *Server) onPaceTimer() { | 73 | func (s *Server) onPaceTimer() { | ||
| 62 | var buf [8]byte | 74 | var buf [8]byte | ||
| 63 | - | _, _ = unix.Read(s.paceFD, buf[:]) // level-triggered: consume the expiry | 75 | + | // Level-triggered: consume the expiry. EAGAIN: the timer was disarmed |
| 76 | + | // after the loop saw it readable. | |||
| 77 | + | if _, err := unix.Read(s.paceFD, buf[:]); err != nil && !errors.Is(err, unix.EAGAIN) { | |||
| 78 | + | s.warnPace("read", err) | |||
| 79 | + | } | |||
| 64 | s.paceTick(nil, nil) | 80 | s.paceTick(nil, nil) | ||
| 65 | } | 81 | } | ||
| 66 | 82 | ||||
M internal/adapters/wayland/server.go +5 -2
| @@ -156,6 +156,7 @@ type Server struct { | |||
| 156 | 156 | // goroutine only). paceSignal coalesces posted ticks (paceSignalFn). | |
| 157 | 157 | paceFD int | |
| 158 | 158 | paceReady bool | |
| 159 | + | paceWarned bool // a pace timer failure was logged (once) | |
| 159 | 160 | paceSignal atomic.Bool | |
| 160 | 161 | paceSignalFn func() | |
| 161 | 162 | paceDepth int | |
| @@ -348,7 +349,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 348 | 349 | positioners: map[*server.Resource]*positioner{}, | |
| 349 | 350 | seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}, | |
| 350 | 351 | } | |
| 352 | + | // Bound once, before anything can post them (the sync waiter calls | |
| 353 | + | // signalPacer from its own goroutine as soon as it exists). | |
| 354 | + | s.paceSignalFn = s.paceSignalled | |
| 351 | 355 | s.flushLinksFn = s.flushLinks | |
| 356 | + | s.sink = newPresentSink(s) | |
| 352 | 357 | s.leaseDevices = map[string]*leaseDevice{} | |
| 353 | 358 | s.pendingLeases = map[uint64]*leaseObject{} | |
| 354 | 359 | s.pendingLeaseCards = map[uint64]string{} | |
| @@ -421,8 +426,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 421 | 426 | s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap | |
| 422 | 427 | } | |
| 423 | 428 | s.syncWait.log = log | |
| 424 | - | s.sink = newPresentSink(s) | |
| 425 | - | s.paceSignalFn = s.paceSignalled | |
| 426 | 429 | s.cleanup = func() { | |
| 427 | 430 | if s.paceReady { | |
| 428 | 431 | unix.Close(s.paceFD) | |
| @@ -156,6 +156,7 @@ type Server struct { | |||||
| 156 | // goroutine only). paceSignal coalesces posted ticks (paceSignalFn). | 156 | // goroutine only). paceSignal coalesces posted ticks (paceSignalFn). | ||
| 157 | paceFD int | 157 | paceFD int | ||
| 158 | paceReady bool | 158 | paceReady bool | ||
| 159 | + | paceWarned bool // a pace timer failure was logged (once) | |||
| 159 | paceSignal atomic.Bool | 160 | paceSignal atomic.Bool | ||
| 160 | paceSignalFn func() | 161 | paceSignalFn func() | ||
| 161 | paceDepth int | 162 | paceDepth int | ||
| @@ -348,7 +349,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 348 | positioners: map[*server.Resource]*positioner{}, | 349 | positioners: map[*server.Resource]*positioner{}, | ||
| 349 | seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}, | 350 | seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}, | ||
| 350 | } | 351 | } | ||
| 352 | + | // Bound once, before anything can post them (the sync waiter calls | |||
| 353 | + | // signalPacer from its own goroutine as soon as it exists). | |||
| 354 | + | s.paceSignalFn = s.paceSignalled | |||
| 351 | s.flushLinksFn = s.flushLinks | 355 | s.flushLinksFn = s.flushLinks | ||
| 356 | + | s.sink = newPresentSink(s) | |||
| 352 | s.leaseDevices = map[string]*leaseDevice{} | 357 | s.leaseDevices = map[string]*leaseDevice{} | ||
| 353 | s.pendingLeases = map[uint64]*leaseObject{} | 358 | s.pendingLeases = map[uint64]*leaseObject{} | ||
| 354 | s.pendingLeaseCards = map[uint64]string{} | 359 | s.pendingLeaseCards = map[uint64]string{} | ||
| @@ -421,8 +426,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 421 | s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap | 426 | s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap | ||
| 422 | } | 427 | } | ||
| 423 | s.syncWait.log = log | 428 | s.syncWait.log = log | ||
| 424 | - | s.sink = newPresentSink(s) | |||
| 425 | - | s.paceSignalFn = s.paceSignalled | |||
| 426 | s.cleanup = func() { | 429 | s.cleanup = func() { | ||
| 427 | if s.paceReady { | 430 | if s.paceReady { | ||
| 428 | unix.Close(s.paceFD) | 431 | unix.Close(s.paceFD) | ||
D internal/app/contentattach.go +0 -15
| @@ -1,15 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/neferwl/internal/adapters/wayland" | |
| 5 | - | "github.com/bnema/neferwl/internal/ports" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // waylandContent is the contentAttacher of a wayland server. | |
| 9 | - | type waylandContent struct{ server *wayland.Server } | |
| 10 | - | ||
| 11 | - | func (w waylandContent) Attach(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |
| 12 | - | return w.server.AttachOutput(name) | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | func (w waylandContent) Detach(name string) { w.server.DetachOutput(name) } | |
| @@ -1,15 +0,0 @@ | |||||
| 1 | - | package app | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/neferwl/internal/adapters/wayland" | |||
| 5 | - | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // waylandContent is the contentAttacher of a wayland server. | |||
| 9 | - | type waylandContent struct{ server *wayland.Server } | |||
| 10 | - | ||||
| 11 | - | func (w waylandContent) Attach(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |||
| 12 | - | return w.server.AttachOutput(name) | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | func (w waylandContent) Detach(name string) { w.server.DetachOutput(name) } | |||
R internal/app/contentattach_test.go → internal/app/contentattacher_test.go +0 -0 renamed, 100% similar
renamed, 100% similar