docs(ports): state sink ownership and when outputs wake core
PresentationSink owns a pushed report and may update its Flip; OutputFrame is sent only for flips of an animating scene. The content attacher documents its name-keyed detach ordering. Display-goroutine ownership notes replace stale pacer references, emitContent checks Empty once, and the docs cover animation wakes, the cumulative headless frame count and the bench requirements.
- commit
- e6c3bc79f2e83faf737072331daf30dd4643736e
- author
- brice · · 2026-10-14 03:07 UTC
- committer
- brice · · 2026-10-14 04:34 UTC
- parent
- 40240cd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +24 -16
M internal/adapters/outputkit/presented/queue.go +5 -4
| @@ -11,10 +11,11 @@ import ( | |||
| 11 | 11 | // Queue holds the reports an output has not pushed to wayland yet. The owner | |
| 12 | 12 | // goroutine flushes it every loop turn, so it holds the few reports of one | |
| 13 | 13 | // turn; the sink alone bounds what waits for the display loop (and merges | |
| 14 | - | // flips when that stalls). Its owner goroutine is the only user. The Seen and ChildReads maps of a queued report are immutable | |
| 15 | - | // snapshots: wayland may still read an earlier report on another goroutine, | |
| 16 | - | // so a snapshot is replaced by a clone when its content changes and an | |
| 17 | - | // unchanged report allocates nothing. | |
| 14 | + | // flips when that stalls). Its owner goroutine is the only user. The Seen and | |
| 15 | + | // ChildReads maps of a queued report are immutable snapshots: wayland may | |
| 16 | + | // still read an earlier report on another goroutine, so a snapshot is | |
| 17 | + | // replaced by a clone when its content changes and an unchanged report | |
| 18 | + | // allocates nothing. | |
| 18 | 19 | type Queue struct { | |
| 19 | 20 | unsent []ports.OutputPresented | |
| 20 | 21 | seen, reads map[ports.WindowID]uint64 | |
| @@ -11,10 +11,11 @@ import ( | |||||
| 11 | // Queue holds the reports an output has not pushed to wayland yet. The owner | 11 | // Queue holds the reports an output has not pushed to wayland yet. The owner | ||
| 12 | // goroutine flushes it every loop turn, so it holds the few reports of one | 12 | // goroutine flushes it every loop turn, so it holds the few reports of one | ||
| 13 | // turn; the sink alone bounds what waits for the display loop (and merges | 13 | // turn; the sink alone bounds what waits for the display loop (and merges | ||
| 14 | - | // flips when that stalls). Its owner goroutine is the only user. The Seen and ChildReads maps of a queued report are immutable | 14 | + | // flips when that stalls). Its owner goroutine is the only user. The Seen and |
| 15 | - | // snapshots: wayland may still read an earlier report on another goroutine, | 15 | + | // ChildReads maps of a queued report are immutable snapshots: wayland may |
| 16 | - | // so a snapshot is replaced by a clone when its content changes and an | 16 | + | // still read an earlier report on another goroutine, so a snapshot is |
| 17 | - | // unchanged report allocates nothing. | 17 | + | // replaced by a clone when its content changes and an unchanged report |
| 18 | + | // allocates nothing. | |||
| 18 | type Queue struct { | 19 | type Queue struct { | ||
| 19 | unsent []ports.OutputPresented | 20 | unsent []ports.OutputPresented | ||
| 20 | seen, reads map[ports.WindowID]uint64 | 21 | seen, reads map[ports.WindowID]uint64 | ||
M internal/adapters/wayland/server.go +2 -4
| @@ -233,7 +233,7 @@ type Server struct { | |||
| 233 | 233 | syncDev syncobjDevice | |
| 234 | 234 | timelines map[*server.Resource]*timeline | |
| 235 | 235 | syncWait *syncWaiter | |
| 236 | - | // flips is each output's latest flip (pacer, under Do). | |
| 236 | + | // flips is each output's latest flip (display goroutine). | |
| 237 | 237 | flips map[string]ports.FlipInfo | |
| 238 | 238 | // inhibitors are the live inhibitor objects (inhibit.go); | |
| 239 | 239 | // shortcutWindows and idleWindows what core was told. | |
| @@ -695,14 +695,12 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 695 | 695 | old = grown | |
| 696 | 696 | } | |
| 697 | 697 | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | |
| 698 | + | c.DamageHistory = h[:len(h):len(h)] | |
| 698 | 699 | if c.Empty() { | |
| 699 | 700 | delete(s.damage, c.ID) | |
| 700 | 701 | delete(s.latest, c.ID) | |
| 701 | 702 | } else { | |
| 702 | 703 | s.damage[c.ID] = h | |
| 703 | - | } | |
| 704 | - | c.DamageHistory = h[:len(h):len(h)] | |
| 705 | - | if !c.Empty() { | |
| 706 | 704 | s.latest[c.ID] = c | |
| 707 | 705 | } | |
| 708 | 706 | for _, l := range s.links { | |
| @@ -233,7 +233,7 @@ type Server struct { | |||||
| 233 | syncDev syncobjDevice | 233 | syncDev syncobjDevice | ||
| 234 | timelines map[*server.Resource]*timeline | 234 | timelines map[*server.Resource]*timeline | ||
| 235 | syncWait *syncWaiter | 235 | syncWait *syncWaiter | ||
| 236 | - | // flips is each output's latest flip (pacer, under Do). | 236 | + | // flips is each output's latest flip (display goroutine). |
| 237 | flips map[string]ports.FlipInfo | 237 | flips map[string]ports.FlipInfo | ||
| 238 | // inhibitors are the live inhibitor objects (inhibit.go); | 238 | // inhibitors are the live inhibitor objects (inhibit.go); | ||
| 239 | // shortcutWindows and idleWindows what core was told. | 239 | // shortcutWindows and idleWindows what core was told. | ||
| @@ -695,14 +695,12 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||||
| 695 | old = grown | 695 | old = grown | ||
| 696 | } | 696 | } | ||
| 697 | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | 697 | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | ||
| 698 | + | c.DamageHistory = h[:len(h):len(h)] | |||
| 698 | if c.Empty() { | 699 | if c.Empty() { | ||
| 699 | delete(s.damage, c.ID) | 700 | delete(s.damage, c.ID) | ||
| 700 | delete(s.latest, c.ID) | 701 | delete(s.latest, c.ID) | ||
| 701 | } else { | 702 | } else { | ||
| 702 | s.damage[c.ID] = h | 703 | s.damage[c.ID] = h | ||
| 703 | - | } | |||
| 704 | - | c.DamageHistory = h[:len(h):len(h)] | |||
| 705 | - | if !c.Empty() { | |||
| 706 | s.latest[c.ID] = c | 704 | s.latest[c.ID] = c | ||
| 707 | } | 705 | } | ||
| 708 | for _, l := range s.links { | 706 | for _, l := range s.links { | ||
M internal/adapters/wayland/syncobj.go +1 -1
| @@ -219,7 +219,7 @@ type syncWaiter struct { | |||
| 219 | 219 | // syncWait is one acquire point a queued commit waits for. | |
| 220 | 220 | type syncWait struct { | |
| 221 | 221 | efd int | |
| 222 | - | done bool // the eventfd fired or the wait was cancelled (pacer, under Do) | |
| 222 | + | done bool // the eventfd fired or the wait was cancelled (display goroutine) | |
| 223 | 223 | failed bool // observation failed; done must not authorize publication | |
| 224 | 224 | cancelled atomic.Bool // run stops polling it and closes efd | |
| 225 | 225 | } | |
| @@ -219,7 +219,7 @@ type syncWaiter struct { | |||||
| 219 | // syncWait is one acquire point a queued commit waits for. | 219 | // syncWait is one acquire point a queued commit waits for. | ||
| 220 | type syncWait struct { | 220 | type syncWait struct { | ||
| 221 | efd int | 221 | efd int | ||
| 222 | - | done bool // the eventfd fired or the wait was cancelled (pacer, under Do) | 222 | + | done bool // the eventfd fired or the wait was cancelled (display goroutine) |
| 223 | failed bool // observation failed; done must not authorize publication | 223 | failed bool // observation failed; done must not authorize publication | ||
| 224 | cancelled atomic.Bool // run stops polling it and closes efd | 224 | cancelled atomic.Bool // run stops polling it and closes efd | ||
| 225 | } | 225 | } | ||
M internal/app/outputs.go +3 -0
| @@ -20,6 +20,9 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c | |||
| 20 | 20 | // content of each window, and takes the batches back through the returned | |
| 21 | 21 | // ports.ContentReturn. AttachOutput may block on the display goroutine: call it | |
| 22 | 22 | // from outside that goroutine. It returns a nil channel when wayland is gone. | |
| 23 | + | // Feeds are keyed by output name: attaching a name replaces its feed, and | |
| 24 | + | // DetachOutput stops whichever feed holds the name. Detach an output only once | |
| 25 | + | // it stopped, and before an output of the same name attaches again. | |
| 23 | 26 | type contentAttacher interface { | |
| 24 | 27 | AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) | |
| 25 | 28 | DetachOutput(name string) | |
| @@ -20,6 +20,9 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c | |||||
| 20 | // content of each window, and takes the batches back through the returned | 20 | // content of each window, and takes the batches back through the returned | ||
| 21 | // ports.ContentReturn. AttachOutput may block on the display goroutine: call it | 21 | // ports.ContentReturn. AttachOutput may block on the display goroutine: call it | ||
| 22 | // from outside that goroutine. It returns a nil channel when wayland is gone. | 22 | // from outside that goroutine. It returns a nil channel when wayland is gone. | ||
| 23 | + | // Feeds are keyed by output name: attaching a name replaces its feed, and | |||
| 24 | + | // DetachOutput stops whichever feed holds the name. Detach an output only once | |||
| 25 | + | // it stopped, and before an output of the same name attaches again. | |||
| 23 | type contentAttacher interface { | 26 | type contentAttacher interface { | ||
| 24 | AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) | 27 | AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) | ||
| 25 | DetachOutput(name string) | 28 | DetachOutput(name string) | ||
M internal/ports/output_events.go +7 -3
| @@ -95,7 +95,10 @@ type OutputPresented struct { | |||
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | // PresentationSink receives output → wayland reports. Push never blocks; | |
| 98 | - | // it wakes the consumer. Owner goroutines of outputs call it. | |
| 98 | + | // it wakes the consumer. Owner goroutines of outputs call it. After Push the | |
| 99 | + | // report belongs to the sink: its maps are read-only snapshots, and Flip must | |
| 100 | + | // be fresh for every push, since the sink may update Merged and replace Shows | |
| 101 | + | // when it merges flips. | |
| 99 | 102 | type PresentationSink interface{ Push(OutputPresented) } | |
| 100 | 103 | ||
| 101 | 104 | // OutputFormats carries output → wayland the dmabuf formats an output can | |
| @@ -137,8 +140,9 @@ type FlipInfo struct { | |||
| 137 | 140 | Shows map[WindowID]uint64 | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | - | // OutputFrame carries output → core a completed page flip: the next frame | |
| 141 | - | // of a running animation is due. | |
| 143 | + | // OutputFrame carries output → core a completed flip of an animating scene | |
| 144 | + | // (Scene.Animating): the next frame is due. Sends never block; a full | |
| 145 | + | // channel drops it. | |
| 142 | 146 | type OutputFrame struct{ Output string } | |
| 143 | 147 | ||
| 144 | 148 | // OutputRemoved carries output → core an unplugged display. | |
| @@ -95,7 +95,10 @@ type OutputPresented struct { | |||||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | // PresentationSink receives output → wayland reports. Push never blocks; | 97 | // PresentationSink receives output → wayland reports. Push never blocks; | ||
| 98 | - | // it wakes the consumer. Owner goroutines of outputs call it. | 98 | + | // it wakes the consumer. Owner goroutines of outputs call it. After Push the |
| 99 | + | // report belongs to the sink: its maps are read-only snapshots, and Flip must | |||
| 100 | + | // be fresh for every push, since the sink may update Merged and replace Shows | |||
| 101 | + | // when it merges flips. | |||
| 99 | type PresentationSink interface{ Push(OutputPresented) } | 102 | type PresentationSink interface{ Push(OutputPresented) } | ||
| 100 | 103 | ||||
| 101 | // OutputFormats carries output → wayland the dmabuf formats an output can | 104 | // OutputFormats carries output → wayland the dmabuf formats an output can | ||
| @@ -137,8 +140,9 @@ type FlipInfo struct { | |||||
| 137 | Shows map[WindowID]uint64 | 140 | Shows map[WindowID]uint64 | ||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | - | // OutputFrame carries output → core a completed page flip: the next frame | 143 | + | // OutputFrame carries output → core a completed flip of an animating scene |
| 141 | - | // of a running animation is due. | 144 | + | // (Scene.Animating): the next frame is due. Sends never block; a full |
| 145 | + | // channel drops it. | |||
| 142 | type OutputFrame struct{ Output string } | 146 | type OutputFrame struct{ Output string } | ||
| 143 | 147 | ||||
| 144 | // OutputRemoved carries output → core an unplugged display. | 148 | // OutputRemoved carries output → core an unplugged display. | ||
M internal/ports/scene.go +4 -4
| @@ -79,10 +79,10 @@ type Scene struct { | |||
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | // SameAs reports whether o draws exactly what s draws: every field other | |
| 82 | - | // than Seq and Animating (scheduling only, not drawn) is equal. A scene carrying a CaptureScene is never the same | |
| 83 | - | // (conservative: the capture image is not compared). Add every new Scene | |
| 84 | - | // field here; TestSceneSameAsCoversEveryField fails until the field count | |
| 85 | - | // is updated. | |
| 82 | + | // than Seq and Animating (scheduling only, not drawn) is equal. A scene | |
| 83 | + | // carrying a CaptureScene is never the same (conservative: the capture image | |
| 84 | + | // is not compared). Add every new Scene field here; | |
| 85 | + | // TestSceneSameAsCoversEveryField fails until the field count is updated. | |
| 86 | 86 | func (s Scene) SameAs(o Scene) bool { | |
| 87 | 87 | if s.Security != o.Security || s.Output != o.Output || | |
| 88 | 88 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | |
| @@ -79,10 +79,10 @@ type Scene struct { | |||||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | // SameAs reports whether o draws exactly what s draws: every field other | 81 | // SameAs reports whether o draws exactly what s draws: every field other | ||
| 82 | - | // than Seq and Animating (scheduling only, not drawn) is equal. A scene carrying a CaptureScene is never the same | 82 | + | // than Seq and Animating (scheduling only, not drawn) is equal. A scene |
| 83 | - | // (conservative: the capture image is not compared). Add every new Scene | 83 | + | // carrying a CaptureScene is never the same (conservative: the capture image |
| 84 | - | // field here; TestSceneSameAsCoversEveryField fails until the field count | 84 | + | // is not compared). Add every new Scene field here; |
| 85 | - | // is updated. | 85 | + | // TestSceneSameAsCoversEveryField fails until the field count is updated. |
| 86 | func (s Scene) SameAs(o Scene) bool { | 86 | func (s Scene) SameAs(o Scene) bool { | ||
| 87 | if s.Security != o.Security || s.Output != o.Output || | 87 | if s.Security != o.Security || s.Output != o.Output || | ||
| 88 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | 88 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | ||
M tools/bench/README.md +2 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | `bench.sh` measures NeferWL's CPU time and voluntary context switches per presented frame on a 5120x2160 headless output while chromium animates a small part of a page. | |
| 4 | 4 | ||
| 5 | + | Requires `go`, `chromium` and `python3`. Frames are counted from the renderer's `frame` debug entries, which the script turns on. | |
| 6 | + | ||
| 5 | 7 | tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps | |
| 6 | 8 | tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps | |
| 7 | 9 | ||
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | `bench.sh` measures NeferWL's CPU time and voluntary context switches per presented frame on a 5120x2160 headless output while chromium animates a small part of a page. | 3 | `bench.sh` measures NeferWL's CPU time and voluntary context switches per presented frame on a 5120x2160 headless output while chromium animates a small part of a page. | ||
| 4 | 4 | ||||
| 5 | + | Requires `go`, `chromium` and `python3`. Frames are counted from the renderer's `frame` debug entries, which the script turns on. | |||
| 6 | + | ||||
| 5 | tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps | 7 | tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps | ||
| 6 | tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps | 8 | tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps | ||
| 7 | 9 | ||||