detached e6c3bc7

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
1111 // Queue holds the reports an output has not pushed to wayland yet. The owner
1212 // goroutine flushes it every loop turn, so it holds the few reports of one
1313 // 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.
1819 type Queue struct {
1920 unsent []ports.OutputPresented
2021 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 owner11 // 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 one12 // 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 merges13 // 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 immutable14+// 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 an16+// 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.OutputPresented20 unsent []ports.OutputPresented
20 seen, reads map[ports.WindowID]uint6421 seen, reads map[ports.WindowID]uint64
M internal/adapters/wayland/server.go +2 -4
@@ -233,7 +233,7 @@ type Server struct {
233233 syncDev syncobjDevice
234234 timelines map[*server.Resource]*timeline
235235 syncWait *syncWaiter
236- // flips is each output's latest flip (pacer, under Do).
236+ // flips is each output's latest flip (display goroutine).
237237 flips map[string]ports.FlipInfo
238238 // inhibitors are the live inhibitor objects (inhibit.go);
239239 // shortcutWindows and idleWindows what core was told.
@@ -695,14 +695,12 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
695695 old = grown
696696 }
697697 h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects})
698+ c.DamageHistory = h[:len(h):len(h)]
698699 if c.Empty() {
699700 delete(s.damage, c.ID)
700701 delete(s.latest, c.ID)
701702 } else {
702703 s.damage[c.ID] = h
703- }
704- c.DamageHistory = h[:len(h):len(h)]
705- if !c.Empty() {
706704 s.latest[c.ID] = c
707705 }
708706 for _, l := range s.links {
@@ -233,7 +233,7 @@ type Server struct {
233 syncDev syncobjDevice233 syncDev syncobjDevice
234 timelines map[*server.Resource]*timeline234 timelines map[*server.Resource]*timeline
235 syncWait *syncWaiter235 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.FlipInfo237 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 = grown695 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] = h703 s.damage[c.ID] = h
703- }
704- c.DamageHistory = h[:len(h):len(h)]
705- if !c.Empty() {
706 s.latest[c.ID] = c704 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 {
219219 // syncWait is one acquire point a queued commit waits for.
220220 type syncWait struct {
221221 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)
223223 failed bool // observation failed; done must not authorize publication
224224 cancelled atomic.Bool // run stops polling it and closes efd
225225 }
@@ -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 int221 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 publication223 failed bool // observation failed; done must not authorize publication
224 cancelled atomic.Bool // run stops polling it and closes efd224 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
2020 // content of each window, and takes the batches back through the returned
2121 // ports.ContentReturn. AttachOutput may block on the display goroutine: call it
2222 // 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.
2326 type contentAttacher interface {
2427 AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn)
2528 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 returned20 // content of each window, and takes the batches back through the returned
21 // ports.ContentReturn. AttachOutput may block on the display goroutine: call it21 // 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 {
9595 }
9696
9797 // 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.
99102 type PresentationSink interface{ Push(OutputPresented) }
100103
101104 // OutputFormats carries output → wayland the dmabuf formats an output can
@@ -137,8 +140,9 @@ type FlipInfo struct {
137140 Shows map[WindowID]uint64
138141 }
139142
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.
142146 type OutputFrame struct{ Output string }
143147
144148 // 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 can104 // OutputFormats carries output → wayland the dmabuf formats an output can
@@ -137,8 +140,9 @@ type FlipInfo struct {
137 Shows map[WindowID]uint64140 Shows map[WindowID]uint64
138 }141 }
139 142
140-// OutputFrame carries output → core a completed page flip: the next frame143+// 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 {
7979 }
8080
8181 // 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.
8686 func (s Scene) SameAs(o Scene) bool {
8787 if s.Security != o.Security || s.Output != o.Output ||
8888 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 other81 // 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 same82+// than Seq and Animating (scheduling only, not drawn) is equal. A scene
83-// (conservative: the capture image is not compared). Add every new Scene83+// carrying a CaptureScene is never the same (conservative: the capture image
84-// field here; TestSceneSameAsCoversEveryField fails until the field count84+// 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 @@
22
33 `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.
44
5+Requires `go`, `chromium` and `python3`. Frames are counted from the renderer's `frame` debug entries, which the script turns on.
6+
57 tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps
68 tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps
79
@@ -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 fps7 tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps
6 tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps8 tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps
7 9

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 25

Tags 10

↑↓ moveenter openesc close