detached d2493da

perf(core): build state, workspace and output snapshots in owner scratch

publishState, publishWorkspaces and syncOutputs built fresh snapshots on
every publish only to compare them with the last sent one. They now build
into reused owner-goroutine scratch and copy out only when the snapshot
changed, so a sent value never shares storage with the scratch.

An animation frame on two screens with the state and workspace
subscribers attached: 32 -> 5 allocs, 10.7 KB -> 4.9 KB, 12.7 -> 8.7 us
(BenchmarkPublishSubscribers). The publish budgets drop to 5 (frame) and
3 (leaving). TestPublishSubscribersAllocations guards the budget and the
storage separation and joins perf-check.

Refs: Forgejo #9, GitHub #135
commit
d2493da2bf9da57b95d9f40fc69d210efdd9fd0c
author
brice · · 2026-10-08 09:04 UTC
committer
brice · · 2026-10-08 09:59 UTC
parent
4fd770d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +219 -32
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/core/core.go +16 -1
@@ -75,6 +75,7 @@ type Core struct {
7575 // realBuf backs the layout read through at once by publish (each
7676 // workspace while overviewReal is filled) and by state(); never retained.
7777 realBuf []Placement
78+ scratch publishScratch
7879 security ports.SecurityState
7980 lock lockState
8081 inputKeys map[string]bool
@@ -149,6 +150,17 @@ type Core struct {
149150 pulse focusPulse
150151 }
151152
153+// publishScratch is where publishState and publishWorkspaces build their
154+// snapshots each publish: owner goroutine storage, never sent (a snapshot
155+// is copied out of it when it changed).
156+type publishScratch struct {
157+ state ports.State
158+ workspaces ports.Workspaces
159+ shown map[WindowID]bool
160+ ids []WindowID
161+ layout ports.Layout
162+}
163+
152164 // sentCache is what core last sent to wayland and on its latest-only
153165 // channels; an unchanged value is not sent again.
154166 type sentCache struct {
@@ -838,10 +850,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
838850 // syncOutputs sends the output layout when it changed; input learns the
839851 // geometry only when the outputs themselves moved.
840852 func (c *Core) syncOutputs(ctx context.Context) error {
841- v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
853+ // Built in the scratch every publish, copied only when sent.
854+ c.scratch.layout = c.layoutInto(c.scratch.layout)
855+ v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()}
842856 if sameOutputs(v, c.sent.outputs) {
843857 return nil
844858 }
859+ v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...)
845860 if err := c.command(ctx, v); err != nil {
846861 return err
847862 }
@@ -75,6 +75,7 @@ type Core struct {
75 // realBuf backs the layout read through at once by publish (each75 // realBuf backs the layout read through at once by publish (each
76 // workspace while overviewReal is filled) and by state(); never retained.76 // workspace while overviewReal is filled) and by state(); never retained.
77 realBuf []Placement77 realBuf []Placement
78+ scratch publishScratch
78 security ports.SecurityState79 security ports.SecurityState
79 lock lockState80 lock lockState
80 inputKeys map[string]bool81 inputKeys map[string]bool
@@ -149,6 +150,17 @@ type Core struct {
149 pulse focusPulse150 pulse focusPulse
150 }151 }
151 152
153+// publishScratch is where publishState and publishWorkspaces build their
154+// snapshots each publish: owner goroutine storage, never sent (a snapshot
155+// is copied out of it when it changed).
156+type publishScratch struct {
157+ state ports.State
158+ workspaces ports.Workspaces
159+ shown map[WindowID]bool
160+ ids []WindowID
161+ layout ports.Layout
162+}
163+
152 // sentCache is what core last sent to wayland and on its latest-only164 // sentCache is what core last sent to wayland and on its latest-only
153 // channels; an unchanged value is not sent again.165 // channels; an unchanged value is not sent again.
154 type sentCache struct {166 type sentCache struct {
@@ -838,10 +850,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
838 // syncOutputs sends the output layout when it changed; input learns the850 // syncOutputs sends the output layout when it changed; input learns the
839 // geometry only when the outputs themselves moved.851 // geometry only when the outputs themselves moved.
840 func (c *Core) syncOutputs(ctx context.Context) error {852 func (c *Core) syncOutputs(ctx context.Context) error {
841- v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}853+ // Built in the scratch every publish, copied only when sent.
854+ c.scratch.layout = c.layoutInto(c.scratch.layout)
855+ v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()}
842 if sameOutputs(v, c.sent.outputs) {856 if sameOutputs(v, c.sent.outputs) {
843 return nil857 return nil
844 }858 }
859+ v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...)
845 if err := c.command(ctx, v); err != nil {860 if err := c.command(ctx, v); err != nil {
846 return err861 return err
847 }862 }
M internal/core/layout.go +8 -6
@@ -305,18 +305,20 @@ func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
305305
306306 // windows lists every window: columns first, then the stash, then native
307307 // floats.
308-func (w *Workspace) windows() []WindowID {
309- var ids []WindowID
308+func (w *Workspace) windows() []WindowID { return w.appendWindows(nil) }
309+
310+// appendWindows appends windows' list to dst.
311+func (w *Workspace) appendWindows(dst []WindowID) []WindowID {
310312 for _, c := range w.Columns {
311- ids = append(ids, c.Windows...)
313+ dst = append(dst, c.Windows...)
312314 }
313315 for _, f := range w.Stash {
314- ids = append(ids, f.ID)
316+ dst = append(dst, f.ID)
315317 }
316318 for _, f := range w.Floats {
317- ids = append(ids, f.ID)
319+ dst = append(dst, f.ID)
318320 }
319- return ids
321+ return dst
320322 }
321323
322324 // cell is the 1-based column and row of a tiled window, 0, 0 otherwise.
@@ -305,18 +305,20 @@ func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
305 305
306 // windows lists every window: columns first, then the stash, then native306 // windows lists every window: columns first, then the stash, then native
307 // floats.307 // floats.
308-func (w *Workspace) windows() []WindowID {308+func (w *Workspace) windows() []WindowID { return w.appendWindows(nil) }
309- var ids []WindowID309+
310+// appendWindows appends windows' list to dst.
311+func (w *Workspace) appendWindows(dst []WindowID) []WindowID {
310 for _, c := range w.Columns {312 for _, c := range w.Columns {
311- ids = append(ids, c.Windows...)313+ dst = append(dst, c.Windows...)
312 }314 }
313 for _, f := range w.Stash {315 for _, f := range w.Stash {
314- ids = append(ids, f.ID)316+ dst = append(dst, f.ID)
315 }317 }
316 for _, f := range w.Floats {318 for _, f := range w.Floats {
317- ids = append(ids, f.ID)319+ dst = append(dst, f.ID)
318 }320 }
319- return ids321+ return dst
320 }322 }
321 323
322 // cell is the 1-based column and row of a tiled window, 0, 0 otherwise.324 // cell is the 1-based column and row of a tiled window, 0, 0 otherwise.
M internal/core/publish_alloc_test.go +124 -6
@@ -2,6 +2,8 @@ package core
22
33 import (
44 "context"
5+ "reflect"
6+ "slices"
57 "testing"
68 "time"
79
@@ -18,15 +20,16 @@ import (
1820 // flip path with another screen animating keeps it and costs the same), then
1921 // 6 once the layouts are built in the owner's reused buffers (layoutInto:
2022 // the screens' layout, the workspaces' column, row and tile rects) instead of
21-// fresh slices. What is left is the scenes themselves (windows, separators,
22-// the scene set) and the screen placements. Leaving entries (a closed window
23-// still fading) add none: 4, less than a frame with six moving windows
23+// fresh slices, and 5 once the output layout is built in the scratch too
24+// (copied only when it changed). What is left is the scenes themselves
25+// (windows, separators, the scene set). Leaving entries (a closed window
26+// still fading) add none: 3, less than a frame with six moving windows
2427 // (fewer configure targets). The opening overview costs 18 (the stack
2528 // items, the row map and the neighbor list it rebuilds every frame; its
2629 // layouts reuse the buffers too).
2730 const (
28- publishAllocBudget = 6
29- publishLeavingAllocBudget = 4
31+ publishAllocBudget = 5
32+ publishLeavingAllocBudget = 3
3033 publishOverviewAllocBudget = 18
3134 )
3235
@@ -34,7 +37,7 @@ const (
3437 // bookkeeping; the fallback timer is then the system one) with screens
3538 // screens of six windows each. The Slowdown cap keeps every motion running
3639 // for seconds, so none settles during a measure even under -race or load.
37-func publishRig(t *testing.T, screens int) *Core {
40+func publishRig(t testing.TB, screens int) *Core {
3841 t.Helper()
3942 var cfg ports.Config
4043 cfg.Keyboard.CmdKey = "super"
@@ -302,3 +305,118 @@ func TestPublishLeavingAllocations(t *testing.T) {
302305 t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
303306 }
304307 }
308+
309+// TestPublishSubscribersAllocations pins the cost of an animation frame
310+// with the state and workspace subscribers attached: their snapshots are
311+// built in the owner's scratch and, unchanged during a camera and rect
312+// animation, add nothing to the frame. Measured: 32 when each publish built
313+// fresh snapshots (state, workspaces and output layout), 5 since. Sent
314+// values never share storage with the scratch they were built in.
315+func TestPublishSubscribersAllocations(t *testing.T) {
316+ c := publishRig(t, 2)
317+ c.ch.State = make(chan ports.State, 1)
318+ c.ch.Workspaces = make(chan ports.Workspaces, 1)
319+ t0 := time.Now()
320+ for _, sc := range c.screens {
321+ if sc.name() == "" {
322+ continue
323+ }
324+ ws := sc.mon.Current()
325+ ws.view.motion = c.spring(viewSpring(100, 0), t0)
326+ ws.view.off = 100
327+ startRectMotions(c, sc, t0)
328+ }
329+ step := publishStep(t, c, nil)
330+ step()
331+ checkRunning(t, c, "setup")
332+ if len(c.ch.State) != 1 || len(c.ch.Workspaces) != 1 {
333+ t.Fatal("setup: no snapshot sent")
334+ }
335+ st := <-c.ch.State
336+ ws := <-c.ch.Workspaces
337+ n := testing.AllocsPerRun(50, step)
338+ t.Logf("subscribers allocs per step = %v", n)
339+ checkRunning(t, c, "motions settled during the measure")
340+ if n > publishAllocBudget {
341+ t.Errorf("publish allocs per frame with subscribers = %v, budget %d", n, publishAllocBudget)
342+ }
343+ if len(c.ch.State) != 0 || len(c.ch.Workspaces) != 0 {
344+ t.Error("an unchanged snapshot was sent again")
345+ }
346+ // Sent values own their storage: the next publish rewrites the scratch
347+ // in place.
348+ sc := &c.scratch
349+ if st.Window == nil || len(st.Windows) == 0 || len(ws.Outputs) == 0 || len(c.sent.outputs.Outputs) == 0 {
350+ t.Fatalf("setup: empty snapshots %+v %+v", st, ws)
351+ }
352+ if &st.Windows[0] == &sc.state.Windows[:1][0] || &st.Outputs[0] == &sc.state.Outputs[:1][0] || inSlice(st.Window, sc.state.Windows) || inSlice(st.Window, st.Windows) {
353+ t.Error("the sent state shares the scratch")
354+ }
355+ for _, sent := range []ports.Workspaces{ws, c.sent.workspaces} {
356+ if &sent.Outputs[0] == &sc.workspaces.Outputs[:1][0] {
357+ t.Error("the sent workspaces share the scratch")
358+ }
359+ for k, o := range sent.Outputs {
360+ if &o.Workspaces[0] == &sc.workspaces.Outputs[k].Workspaces[:1][0] {
361+ t.Errorf("the sent workspaces of %s share the scratch", o.Name)
362+ }
363+ }
364+ }
365+ if &c.sent.outputs.Outputs[0] == &sc.layout[:1][0] {
366+ t.Error("the sent output layout shares the scratch")
367+ }
368+ // And a later change leaves them as they were.
369+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
370+ w := *st.Window
371+ want.Window = &w
372+ c.windows.setIdleInhibit(st.Windows[0].ID, true)
373+ c.publishState()
374+ // A sent value aliasing the scratch would always equal it: nothing sent.
375+ select {
376+ case got := <-c.ch.State:
377+ if !got.Windows[0].IdleInhibit {
378+ t.Fatal("change not published")
379+ }
380+ default:
381+ t.Fatal("change not published")
382+ }
383+ if !reflect.DeepEqual(st, want) {
384+ t.Fatal("a sent snapshot changed after a later publish")
385+ }
386+}
387+
388+func inSlice[T any](p *T, s []T) bool {
389+ s = s[:cap(s)]
390+ for i := range s {
391+ if p == &s[i] {
392+ return true
393+ }
394+ }
395+ return false
396+}
397+
398+// BenchmarkPublishSubscribers is one animation frame on two screens with
399+// the state and workspace subscribers attached.
400+func BenchmarkPublishSubscribers(b *testing.B) {
401+ c := publishRig(b, 2)
402+ c.ch.State = make(chan ports.State, 1)
403+ c.ch.Workspaces = make(chan ports.Workspaces, 1)
404+ t0 := time.Now()
405+ for _, sc := range c.screens {
406+ if sc.name() == "" {
407+ continue
408+ }
409+ ws := sc.mon.Current()
410+ ws.view.motion = c.spring(viewSpring(100, 0), t0)
411+ ws.view.off = 100
412+ startRectMotions(c, sc, t0)
413+ }
414+ ctx := context.Background()
415+ b.ReportAllocs()
416+ for b.Loop() {
417+ if err := c.step(ctx, nil); err != nil {
418+ b.Fatal(err)
419+ }
420+ <-c.ch.Scenes
421+ }
422+}
@@ -2,6 +2,8 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "reflect"
6+ "slices"
5 "testing"7 "testing"
6 "time"8 "time"
7 9
@@ -18,15 +20,16 @@ import (
18 // flip path with another screen animating keeps it and costs the same), then20 // flip path with another screen animating keeps it and costs the same), then
19 // 6 once the layouts are built in the owner's reused buffers (layoutInto:21 // 6 once the layouts are built in the owner's reused buffers (layoutInto:
20 // the screens' layout, the workspaces' column, row and tile rects) instead of22 // the screens' layout, the workspaces' column, row and tile rects) instead of
21-// fresh slices. What is left is the scenes themselves (windows, separators,23+// fresh slices, and 5 once the output layout is built in the scratch too
22-// the scene set) and the screen placements. Leaving entries (a closed window24+// (copied only when it changed). What is left is the scenes themselves
23-// still fading) add none: 4, less than a frame with six moving windows25+// (windows, separators, the scene set). Leaving entries (a closed window
26+// still fading) add none: 3, less than a frame with six moving windows
24 // (fewer configure targets). The opening overview costs 18 (the stack27 // (fewer configure targets). The opening overview costs 18 (the stack
25 // items, the row map and the neighbor list it rebuilds every frame; its28 // items, the row map and the neighbor list it rebuilds every frame; its
26 // layouts reuse the buffers too).29 // layouts reuse the buffers too).
27 const (30 const (
28- publishAllocBudget = 631+ publishAllocBudget = 5
29- publishLeavingAllocBudget = 432+ publishLeavingAllocBudget = 3
30 publishOverviewAllocBudget = 1833 publishOverviewAllocBudget = 18
31 )34 )
32 35
@@ -34,7 +37,7 @@ const (
34 // bookkeeping; the fallback timer is then the system one) with screens37 // bookkeeping; the fallback timer is then the system one) with screens
35 // screens of six windows each. The Slowdown cap keeps every motion running38 // screens of six windows each. The Slowdown cap keeps every motion running
36 // for seconds, so none settles during a measure even under -race or load.39 // for seconds, so none settles during a measure even under -race or load.
37-func publishRig(t *testing.T, screens int) *Core {40+func publishRig(t testing.TB, screens int) *Core {
38 t.Helper()41 t.Helper()
39 var cfg ports.Config42 var cfg ports.Config
40 cfg.Keyboard.CmdKey = "super"43 cfg.Keyboard.CmdKey = "super"
@@ -302,3 +305,118 @@ func TestPublishLeavingAllocations(t *testing.T) {
302 t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)305 t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
303 }306 }
304 }307 }
308+
309+// TestPublishSubscribersAllocations pins the cost of an animation frame
310+// with the state and workspace subscribers attached: their snapshots are
311+// built in the owner's scratch and, unchanged during a camera and rect
312+// animation, add nothing to the frame. Measured: 32 when each publish built
313+// fresh snapshots (state, workspaces and output layout), 5 since. Sent
314+// values never share storage with the scratch they were built in.
315+func TestPublishSubscribersAllocations(t *testing.T) {
316+ c := publishRig(t, 2)
317+ c.ch.State = make(chan ports.State, 1)
318+ c.ch.Workspaces = make(chan ports.Workspaces, 1)
319+ t0 := time.Now()
320+ for _, sc := range c.screens {
321+ if sc.name() == "" {
322+ continue
323+ }
324+ ws := sc.mon.Current()
325+ ws.view.motion = c.spring(viewSpring(100, 0), t0)
326+ ws.view.off = 100
327+ startRectMotions(c, sc, t0)
328+ }
329+ step := publishStep(t, c, nil)
330+ step()
331+ checkRunning(t, c, "setup")
332+ if len(c.ch.State) != 1 || len(c.ch.Workspaces) != 1 {
333+ t.Fatal("setup: no snapshot sent")
334+ }
335+ st := <-c.ch.State
336+ ws := <-c.ch.Workspaces
337+ n := testing.AllocsPerRun(50, step)
338+ t.Logf("subscribers allocs per step = %v", n)
339+ checkRunning(t, c, "motions settled during the measure")
340+ if n > publishAllocBudget {
341+ t.Errorf("publish allocs per frame with subscribers = %v, budget %d", n, publishAllocBudget)
342+ }
343+ if len(c.ch.State) != 0 || len(c.ch.Workspaces) != 0 {
344+ t.Error("an unchanged snapshot was sent again")
345+ }
346+ // Sent values own their storage: the next publish rewrites the scratch
347+ // in place.
348+ sc := &c.scratch
349+ if st.Window == nil || len(st.Windows) == 0 || len(ws.Outputs) == 0 || len(c.sent.outputs.Outputs) == 0 {
350+ t.Fatalf("setup: empty snapshots %+v %+v", st, ws)
351+ }
352+ if &st.Windows[0] == &sc.state.Windows[:1][0] || &st.Outputs[0] == &sc.state.Outputs[:1][0] || inSlice(st.Window, sc.state.Windows) || inSlice(st.Window, st.Windows) {
353+ t.Error("the sent state shares the scratch")
354+ }
355+ for _, sent := range []ports.Workspaces{ws, c.sent.workspaces} {
356+ if &sent.Outputs[0] == &sc.workspaces.Outputs[:1][0] {
357+ t.Error("the sent workspaces share the scratch")
358+ }
359+ for k, o := range sent.Outputs {
360+ if &o.Workspaces[0] == &sc.workspaces.Outputs[k].Workspaces[:1][0] {
361+ t.Errorf("the sent workspaces of %s share the scratch", o.Name)
362+ }
363+ }
364+ }
365+ if &c.sent.outputs.Outputs[0] == &sc.layout[:1][0] {
366+ t.Error("the sent output layout shares the scratch")
367+ }
368+ // And a later change leaves them as they were.
369+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
370+ w := *st.Window
371+ want.Window = &w
372+ c.windows.setIdleInhibit(st.Windows[0].ID, true)
373+ c.publishState()
374+ // A sent value aliasing the scratch would always equal it: nothing sent.
375+ select {
376+ case got := <-c.ch.State:
377+ if !got.Windows[0].IdleInhibit {
378+ t.Fatal("change not published")
379+ }
380+ default:
381+ t.Fatal("change not published")
382+ }
383+ if !reflect.DeepEqual(st, want) {
384+ t.Fatal("a sent snapshot changed after a later publish")
385+ }
386+}
387+
388+func inSlice[T any](p *T, s []T) bool {
389+ s = s[:cap(s)]
390+ for i := range s {
391+ if p == &s[i] {
392+ return true
393+ }
394+ }
395+ return false
396+}
397+
398+// BenchmarkPublishSubscribers is one animation frame on two screens with
399+// the state and workspace subscribers attached.
400+func BenchmarkPublishSubscribers(b *testing.B) {
401+ c := publishRig(b, 2)
402+ c.ch.State = make(chan ports.State, 1)
403+ c.ch.Workspaces = make(chan ports.Workspaces, 1)
404+ t0 := time.Now()
405+ for _, sc := range c.screens {
406+ if sc.name() == "" {
407+ continue
408+ }
409+ ws := sc.mon.Current()
410+ ws.view.motion = c.spring(viewSpring(100, 0), t0)
411+ ws.view.off = 100
412+ startRectMotions(c, sc, t0)
413+ }
414+ ctx := context.Background()
415+ b.ReportAllocs()
416+ for b.Loop() {
417+ if err := c.step(ctx, nil); err != nil {
418+ b.Fatal(err)
419+ }
420+ <-c.ch.Scenes
421+ }
422+}
M internal/core/screens.go +5 -2
@@ -157,8 +157,11 @@ func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
157157 }
158158
159159 // layout lists the connected outputs; the placeholder is not one.
160-func (c *Core) layout() ports.Layout {
161- l := make(ports.Layout, 0, len(c.screens))
160+func (c *Core) layout() ports.Layout { return c.layoutInto(make(ports.Layout, 0, len(c.screens))) }
161+
162+// layoutInto is layout built in l[:0]: the result aliases l's storage.
163+func (c *Core) layoutInto(l ports.Layout) ports.Layout {
164+ l = l[:0]
162165 for _, s := range c.screens {
163166 if s.name() != "" {
164167 l = append(l, s.placement())
@@ -157,8 +157,11 @@ func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
157 }157 }
158 158
159 // layout lists the connected outputs; the placeholder is not one.159 // layout lists the connected outputs; the placeholder is not one.
160-func (c *Core) layout() ports.Layout {160+func (c *Core) layout() ports.Layout { return c.layoutInto(make(ports.Layout, 0, len(c.screens))) }
161- l := make(ports.Layout, 0, len(c.screens))161+
162+// layoutInto is layout built in l[:0]: the result aliases l's storage.
163+func (c *Core) layoutInto(l ports.Layout) ports.Layout {
164+ l = l[:0]
162 for _, s := range c.screens {165 for _, s := range c.screens {
163 if s.name() != "" {166 if s.name() != "" {
164 l = append(l, s.placement())167 l = append(l, s.placement())
M internal/core/state.go +40 -11
@@ -2,28 +2,41 @@ package core
22
33 import (
44 "cmp"
5- "reflect"
65 "slices"
76
87 "github.com/bnema/neferwl/internal/ports"
98 )
109
11-// publishState sends the script-facing snapshot when it changed.
10+// publishState sends the script-facing snapshot when it changed. It is
11+// built in the owner's scratch every publish and copied only when sent: an
12+// animation frame that changes nothing allocates nothing here.
1213 func (c *Core) publishState() {
1314 if c.ch.State == nil {
1415 return
1516 }
16- st := c.state()
17- if reflect.DeepEqual(st, c.sent.state) {
17+ c.scratch.state = c.stateInto(c.scratch.state)
18+ // The first snapshot is always sent, even an empty one.
19+ if c.sent.state.Outputs != nil && sameState(c.scratch.state, c.sent.state) {
1820 return
1921 }
22+ st := cloneState(c.scratch.state)
2023 c.sent.state = st
2124 latest(c.ch.State, st)
2225 }
2326
24-// state builds the snapshot. The placeholder screen is not an output.
25-func (c *Core) state() ports.State {
26- st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}}
27+// state is a fresh snapshot, the caller's to keep.
28+func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{})) }
29+
30+// stateInto builds the snapshot in st's storage: the result aliases it and
31+// its Window points into its Windows. The placeholder screen is not an
32+// output.
33+func (c *Core) stateInto(st ports.State) ports.State {
34+ st.Output, st.Window = c.cur().name(), nil
35+ st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36+ if c.scratch.shown == nil {
37+ c.scratch.shown = map[WindowID]bool{}
38+ }
39+ shown := c.scratch.shown
2740 focus, _ := c.cur().mon.Focused()
2841 for _, s := range c.screens {
2942 if s.name() == "" {
@@ -37,7 +50,7 @@ func (c *Core) state() ports.State {
3750 }
3851 st.Outputs = append(st.Outputs, o)
3952 // Visible follows the configures: drawn on the output.
40- shown := map[WindowID]bool{}
53+ clear(shown)
4154 // Read at once, into publish's scratch: the snapshot holds no
4255 // placement.
4356 c.realBuf = m.layoutInto(c.realBuf)
@@ -46,7 +59,8 @@ func (c *Core) state() ports.State {
4659 }
4760 for w := range m.all() {
4861 n := indexOf(m.Workspaces, w) + 1
49- for _, id := range w.windows() {
62+ c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
63+ for _, id := range c.scratch.ids {
5064 rec := c.windows.lookup(id)
5165 v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)}
5266 if i := w.stashIndex(id); i >= 0 {
@@ -62,13 +76,28 @@ func (c *Core) state() ports.State {
6276 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
6377 for i := range st.Windows {
6478 if st.Windows[i].ID == focus && focus != 0 {
65- w := st.Windows[i]
66- st.Window = &w
79+ st.Window = &st.Windows[i]
6780 }
6881 }
6982 return st
7083 }
7184
85+func sameState(a, b ports.State) bool {
86+ return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
87+ (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
88+}
89+
90+// cloneState copies st out of the scratch: empty lists stay non-nil (JSON
91+// []), and Window is its own copy.
92+func cloneState(st ports.State) ports.State {
93+ out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
94+ if st.Window != nil {
95+ w := *st.Window
96+ out.Window = &w
97+ }
98+ return out
99+}
100+
72101 // numbered counts numbered workspaces, without the trailing spare one
73102 // unless it is on screen.
74103 func numbered(m *Monitor) int {
@@ -2,28 +2,41 @@ package core
2 2
3 import (3 import (
4 "cmp"4 "cmp"
5- "reflect"
6 "slices"5 "slices"
7 6
8 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
9 )8 )
10 9
11-// publishState sends the script-facing snapshot when it changed.10+// publishState sends the script-facing snapshot when it changed. It is
11+// built in the owner's scratch every publish and copied only when sent: an
12+// animation frame that changes nothing allocates nothing here.
12 func (c *Core) publishState() {13 func (c *Core) publishState() {
13 if c.ch.State == nil {14 if c.ch.State == nil {
14 return15 return
15 }16 }
16- st := c.state()17+ c.scratch.state = c.stateInto(c.scratch.state)
17- if reflect.DeepEqual(st, c.sent.state) {18+ // The first snapshot is always sent, even an empty one.
19+ if c.sent.state.Outputs != nil && sameState(c.scratch.state, c.sent.state) {
18 return20 return
19 }21 }
22+ st := cloneState(c.scratch.state)
20 c.sent.state = st23 c.sent.state = st
21 latest(c.ch.State, st)24 latest(c.ch.State, st)
22 }25 }
23 26
24-// state builds the snapshot. The placeholder screen is not an output.27+// state is a fresh snapshot, the caller's to keep.
25-func (c *Core) state() ports.State {28+func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{})) }
26- st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}}29+
30+// stateInto builds the snapshot in st's storage: the result aliases it and
31+// its Window points into its Windows. The placeholder screen is not an
32+// output.
33+func (c *Core) stateInto(st ports.State) ports.State {
34+ st.Output, st.Window = c.cur().name(), nil
35+ st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36+ if c.scratch.shown == nil {
37+ c.scratch.shown = map[WindowID]bool{}
38+ }
39+ shown := c.scratch.shown
27 focus, _ := c.cur().mon.Focused()40 focus, _ := c.cur().mon.Focused()
28 for _, s := range c.screens {41 for _, s := range c.screens {
29 if s.name() == "" {42 if s.name() == "" {
@@ -37,7 +50,7 @@ func (c *Core) state() ports.State {
37 }50 }
38 st.Outputs = append(st.Outputs, o)51 st.Outputs = append(st.Outputs, o)
39 // Visible follows the configures: drawn on the output.52 // Visible follows the configures: drawn on the output.
40- shown := map[WindowID]bool{}53+ clear(shown)
41 // Read at once, into publish's scratch: the snapshot holds no54 // Read at once, into publish's scratch: the snapshot holds no
42 // placement.55 // placement.
43 c.realBuf = m.layoutInto(c.realBuf)56 c.realBuf = m.layoutInto(c.realBuf)
@@ -46,7 +59,8 @@ func (c *Core) state() ports.State {
46 }59 }
47 for w := range m.all() {60 for w := range m.all() {
48 n := indexOf(m.Workspaces, w) + 161 n := indexOf(m.Workspaces, w) + 1
49- for _, id := range w.windows() {62+ c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
63+ for _, id := range c.scratch.ids {
50 rec := c.windows.lookup(id)64 rec := c.windows.lookup(id)
51 v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)}65 v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)}
52 if i := w.stashIndex(id); i >= 0 {66 if i := w.stashIndex(id); i >= 0 {
@@ -62,13 +76,28 @@ func (c *Core) state() ports.State {
62 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })76 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
63 for i := range st.Windows {77 for i := range st.Windows {
64 if st.Windows[i].ID == focus && focus != 0 {78 if st.Windows[i].ID == focus && focus != 0 {
65- w := st.Windows[i]79+ st.Window = &st.Windows[i]
66- st.Window = &w
67 }80 }
68 }81 }
69 return st82 return st
70 }83 }
71 84
85+func sameState(a, b ports.State) bool {
86+ return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
87+ (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
88+}
89+
90+// cloneState copies st out of the scratch: empty lists stay non-nil (JSON
91+// []), and Window is its own copy.
92+func cloneState(st ports.State) ports.State {
93+ out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
94+ if st.Window != nil {
95+ w := *st.Window
96+ out.Window = &w
97+ }
98+ return out
99+}
100+
72 // numbered counts numbered workspaces, without the trailing spare one101 // numbered counts numbered workspaces, without the trailing spare one
73 // unless it is on screen.102 // unless it is on screen.
74 func numbered(m *Monitor) int {103 func numbered(m *Monitor) int {
M internal/core/workspaces.go +25 -5
@@ -1,7 +1,7 @@
11 package core
22
33 import (
4- "reflect"
4+ "slices"
55 "strconv"
66
77 "github.com/bnema/neferwl/internal/ports"
@@ -10,17 +10,25 @@ import (
1010 // publishWorkspaces describes the numbered vertical stack and configured hidden
1111 // workspaces. Config-hidden named workspaces and empty numbered spares are
1212 // hidden unless shown; unlike State, hidden named workspaces remain addressable.
13+// Like publishState, it is built in the owner's scratch and copied only
14+// when sent.
1315 func (c *Core) publishWorkspaces() {
1416 if c.ch.Workspaces == nil {
1517 return
1618 }
17- snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
19+ buf := &c.scratch.workspaces
20+ buf.Outputs = buf.Outputs[:0]
1821 for _, sc := range c.screens {
1922 if sc.name() == "" {
2023 continue
2124 }
2225 m := sc.mon
23- out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}
26+ // Reuse the list of the entry this one overwrites.
27+ var list []ports.WorkspaceInfo
28+ if n := len(buf.Outputs); n < cap(buf.Outputs) {
29+ list = buf.Outputs[:n+1][n].Workspaces[:0]
30+ }
31+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: list}
2432 i := -1
2533 for w := range m.all() {
2634 i++
@@ -30,11 +38,23 @@ func (c *Core) publishWorkspaces() {
3038 }
3139 out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output})
3240 }
33- snapshot.Outputs = append(snapshot.Outputs, out)
41+ buf.Outputs = append(buf.Outputs, out)
3442 }
35- if reflect.DeepEqual(snapshot, c.sent.workspaces) {
43+ // The first snapshot is always sent, even an empty one.
44+ if c.sent.workspaces.Outputs != nil && sameWorkspaces(*buf, c.sent.workspaces) {
3645 return
3746 }
47+ // Empty lists stay non-nil (JSON []).
48+ snapshot := ports.Workspaces{Outputs: make([]ports.WorkspaceOutput, len(buf.Outputs))}
49+ for k, o := range buf.Outputs {
50+ snapshot.Outputs[k] = ports.WorkspaceOutput{Name: o.Name, Workspaces: append([]ports.WorkspaceInfo{}, o.Workspaces...)}
51+ }
3852 c.sent.workspaces = snapshot
3953 latest(c.ch.Workspaces, snapshot)
4054 }
55+
56+func sameWorkspaces(a, b ports.Workspaces) bool {
57+ return slices.EqualFunc(a.Outputs, b.Outputs, func(x, y ports.WorkspaceOutput) bool {
58+ return x.Name == y.Name && slices.Equal(x.Workspaces, y.Workspaces)
59+ })
60+}
@@ -1,7 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4- "reflect"4+ "slices"
5 "strconv"5 "strconv"
6 6
7 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
@@ -10,17 +10,25 @@ import (
10 // publishWorkspaces describes the numbered vertical stack and configured hidden10 // publishWorkspaces describes the numbered vertical stack and configured hidden
11 // workspaces. Config-hidden named workspaces and empty numbered spares are11 // workspaces. Config-hidden named workspaces and empty numbered spares are
12 // hidden unless shown; unlike State, hidden named workspaces remain addressable.12 // hidden unless shown; unlike State, hidden named workspaces remain addressable.
13+// Like publishState, it is built in the owner's scratch and copied only
14+// when sent.
13 func (c *Core) publishWorkspaces() {15 func (c *Core) publishWorkspaces() {
14 if c.ch.Workspaces == nil {16 if c.ch.Workspaces == nil {
15 return17 return
16 }18 }
17- snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}19+ buf := &c.scratch.workspaces
20+ buf.Outputs = buf.Outputs[:0]
18 for _, sc := range c.screens {21 for _, sc := range c.screens {
19 if sc.name() == "" {22 if sc.name() == "" {
20 continue23 continue
21 }24 }
22 m := sc.mon25 m := sc.mon
23- out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}26+ // Reuse the list of the entry this one overwrites.
27+ var list []ports.WorkspaceInfo
28+ if n := len(buf.Outputs); n < cap(buf.Outputs) {
29+ list = buf.Outputs[:n+1][n].Workspaces[:0]
30+ }
31+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: list}
24 i := -132 i := -1
25 for w := range m.all() {33 for w := range m.all() {
26 i++34 i++
@@ -30,11 +38,23 @@ func (c *Core) publishWorkspaces() {
30 }38 }
31 out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output})39 out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output})
32 }40 }
33- snapshot.Outputs = append(snapshot.Outputs, out)41+ buf.Outputs = append(buf.Outputs, out)
34 }42 }
35- if reflect.DeepEqual(snapshot, c.sent.workspaces) {43+ // The first snapshot is always sent, even an empty one.
44+ if c.sent.workspaces.Outputs != nil && sameWorkspaces(*buf, c.sent.workspaces) {
36 return45 return
37 }46 }
47+ // Empty lists stay non-nil (JSON []).
48+ snapshot := ports.Workspaces{Outputs: make([]ports.WorkspaceOutput, len(buf.Outputs))}
49+ for k, o := range buf.Outputs {
50+ snapshot.Outputs[k] = ports.WorkspaceOutput{Name: o.Name, Workspaces: append([]ports.WorkspaceInfo{}, o.Workspaces...)}
51+ }
38 c.sent.workspaces = snapshot52 c.sent.workspaces = snapshot
39 latest(c.ch.Workspaces, snapshot)53 latest(c.ch.Workspaces, snapshot)
40 }54 }
55+
56+func sameWorkspaces(a, b ports.Workspaces) bool {
57+ return slices.EqualFunc(a.Outputs, b.Outputs, func(x, y ports.WorkspaceOutput) bool {
58+ return x.Name == y.Name && slices.Equal(x.Workspaces, y.Workspaces)
59+ })
60+}

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