detached d7dc4d5

refactor(core): queue client commands in an outbox instead of blocking

core sent each client command on the wayland channel and waited when it
was full, so a busy display goroutine stalled keyboard handling, scene
publishing and frame wakes. command() now queues in an outbox and a
sender goroutine owned by Run delivers it in order. Adjacent pointer
motions of one window under the same envelope merge with their deltas
summed; a command sends at once when nothing is queued and the channel
has room.
commit
d7dc4d5c8991b8e9be6ae4dddff64cbe79fe614c
author
brice · · 2026-10-18 05:21 UTC
parent
b5629dc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +496 -68
M internal/core/core.go +34 -12
@@ -142,7 +142,9 @@ type Core struct {
142142 // (clientgesture.go).
143143 gesture clientGesture
144144 // ping carries watchdog probes (Ping); Run answers each one.
145- ping chan chan struct{}
145+ ping chan chan struct{}
146+ // out queues the commands for c.ch.Commands; Run's sender drains it.
147+ out outbox
146148 frameC <-chan time.Time
147149 frameTimer ports.Timer
148150 // shots is the snapshot of what the screens show before an action
@@ -405,7 +407,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
405407 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
406408 }
407409 // A placeholder screen holds windows until the first output arrives.
408- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{})}
410+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}
409411 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
410412 if err := c.apply(cfg); err != nil {
411413 return nil, err
@@ -442,22 +444,28 @@ func (c *Core) now() time.Time {
442444 return time.Now()
443445 }
444446
447+// command queues v for the wayland goroutine and never blocks: a busy
448+// display must not stall keyboard, scenes or frame wakes. The sender
449+// goroutine of Run delivers the queue in order.
445450 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
451+ if err := ctx.Err(); err != nil {
452+ return err
453+ }
446454 if c.inputEpochChanged() {
447455 return errSecurityChanged
448456 }
449- if c.opts.Security != nil {
450- v = ports.SecurityCommand{State: c.security, Command: v}
451- }
452- select {
453- case <-ctx.Done():
454- return ctx.Err()
455- case c.ch.Commands <- v:
456- if c.inputEpochChanged() {
457- return errSecurityChanged
458- }
457+ if c.ch.Commands == nil {
458+ // Nobody receives: queueing would only grow without bound.
459459 return nil
460460 }
461+ c.out.push(c.ch.Commands, v, c.security, c.opts.Security != nil)
462+ // The queued command keeps the epoch it was made under, so a display
463+ // that has moved on drops it. Report the change all the same: the rest
464+ // of the event was decided under the old epoch and must not go on.
465+ if c.inputEpochChanged() {
466+ return errSecurityChanged
467+ }
468+ return nil
461469 }
462470
463471 // clientRect is the placement minus the border on its inset sides.
@@ -1059,6 +1067,20 @@ func (c *Core) Ping(ctx context.Context) {
10591067 }
10601068
10611069 func (c *Core) Run(ctx context.Context) error {
1070+ // The sender stops with Run, not only with ctx: Run also returns on
1071+ // quit and on errors. Run waits for it so nothing sends after Run.
1072+ sendCtx, stopSender := context.WithCancel(ctx)
1073+ senderDone := make(chan struct{})
1074+ go func() {
1075+ defer close(senderDone)
1076+ if c.ch.Commands != nil {
1077+ c.out.run(sendCtx, c.ch.Commands)
1078+ }
1079+ }()
1080+ defer func() {
1081+ stopSender()
1082+ <-senderDone
1083+ }()
10621084 c.syncSecurity()
10631085 defer c.stopFrame()
10641086 defer c.stopCaptureTimer()
@@ -142,7 +142,9 @@ type Core struct {
142 // (clientgesture.go).142 // (clientgesture.go).
143 gesture clientGesture143 gesture clientGesture
144 // ping carries watchdog probes (Ping); Run answers each one.144 // ping carries watchdog probes (Ping); Run answers each one.
145- ping chan chan struct{}145+ ping chan chan struct{}
146+ // out queues the commands for c.ch.Commands; Run's sender drains it.
147+ out outbox
146 frameC <-chan time.Time148 frameC <-chan time.Time
147 frameTimer ports.Timer149 frameTimer ports.Timer
148 // shots is the snapshot of what the screens show before an action150 // shots is the snapshot of what the screens show before an action
@@ -405,7 +407,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
405 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")407 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
406 }408 }
407 // A placeholder screen holds windows until the first output arrives.409 // A placeholder screen holds windows until the first output arrives.
408- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{})}410+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}
409 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}411 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
410 if err := c.apply(cfg); err != nil {412 if err := c.apply(cfg); err != nil {
411 return nil, err413 return nil, err
@@ -442,22 +444,28 @@ func (c *Core) now() time.Time {
442 return time.Now()444 return time.Now()
443 }445 }
444 446
447+// command queues v for the wayland goroutine and never blocks: a busy
448+// display must not stall keyboard, scenes or frame wakes. The sender
449+// goroutine of Run delivers the queue in order.
445 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {450 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
451+ if err := ctx.Err(); err != nil {
452+ return err
453+ }
446 if c.inputEpochChanged() {454 if c.inputEpochChanged() {
447 return errSecurityChanged455 return errSecurityChanged
448 }456 }
449- if c.opts.Security != nil {457+ if c.ch.Commands == nil {
450- v = ports.SecurityCommand{State: c.security, Command: v}458+ // Nobody receives: queueing would only grow without bound.
451- }
452- select {
453- case <-ctx.Done():
454- return ctx.Err()
455- case c.ch.Commands <- v:
456- if c.inputEpochChanged() {
457- return errSecurityChanged
458- }
459 return nil459 return nil
460 }460 }
461+ c.out.push(c.ch.Commands, v, c.security, c.opts.Security != nil)
462+ // The queued command keeps the epoch it was made under, so a display
463+ // that has moved on drops it. Report the change all the same: the rest
464+ // of the event was decided under the old epoch and must not go on.
465+ if c.inputEpochChanged() {
466+ return errSecurityChanged
467+ }
468+ return nil
461 }469 }
462 470
463 // clientRect is the placement minus the border on its inset sides.471 // clientRect is the placement minus the border on its inset sides.
@@ -1059,6 +1067,20 @@ func (c *Core) Ping(ctx context.Context) {
1059 }1067 }
1060 1068
1061 func (c *Core) Run(ctx context.Context) error {1069 func (c *Core) Run(ctx context.Context) error {
1070+ // The sender stops with Run, not only with ctx: Run also returns on
1071+ // quit and on errors. Run waits for it so nothing sends after Run.
1072+ sendCtx, stopSender := context.WithCancel(ctx)
1073+ senderDone := make(chan struct{})
1074+ go func() {
1075+ defer close(senderDone)
1076+ if c.ch.Commands != nil {
1077+ c.out.run(sendCtx, c.ch.Commands)
1078+ }
1079+ }()
1080+ defer func() {
1081+ stopSender()
1082+ <-senderDone
1083+ }()
1062 c.syncSecurity()1084 c.syncSecurity()
1063 defer c.stopFrame()1085 defer c.stopFrame()
1064 defer c.stopCaptureTimer()1086 defer c.stopCaptureTimer()
A internal/core/outbox.go +142 -0
@@ -0,0 +1,142 @@
1+package core
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// outbox carries commands from the owner goroutine to the sender goroutine
11+// without ever blocking the owner. It is a transport queue, not window
12+// state: the mutex guards only the slices. Commands leave in the order they
13+// were queued; the only merge is of adjacent pointer motions (see push).
14+//
15+// The wayland display goroutine can be busy for a while. Sending from the
16+// owner would stall keyboard, scenes and frame wakes behind it, so the owner
17+// queues and the sender goroutine does the blocking send.
18+type outbox struct {
19+ // wake is signalled (never blocking) after each push.
20+ wake chan struct{}
21+
22+ mu sync.Mutex
23+ queue, spare []outCmd
24+ // busy is set while the sender holds a batch it has not finished
25+ // sending: a command pushed then must queue behind it.
26+ busy bool
27+}
28+
29+// outCmd is a queued command. A pointer motion is kept unboxed so that a
30+// merge boxes the sum once, when it leaves, not at every merge.
31+type outCmd struct {
32+ cmd ports.ClientCommand
33+ // state is the owner envelope; secured is false without a security gate.
34+ state ports.SecurityState
35+ secured bool
36+ // motion is cmd when it is a pointer motion. merged means cmd is stale
37+ // and motion carries the sum.
38+ motion ports.PointerMotionTo
39+ isMotion bool
40+ merged bool
41+}
42+
43+func newOutbox() outbox { return outbox{wake: make(chan struct{}, 1)} }
44+
45+// command returns what the sender delivers.
46+func (o *outCmd) command() ports.ClientCommand {
47+ v := o.cmd
48+ if o.merged {
49+ v = o.motion
50+ }
51+ if o.secured {
52+ return ports.SecurityCommand{State: o.state, Command: v}
53+ }
54+ return v
55+}
56+
57+// push delivers v, wrapped in an envelope for state when secured. With
58+// nothing queued or in flight and room in out it sends at once, which keeps
59+// the display's latency when it keeps up; otherwise it queues v. A pointer
60+// motion for the same window and envelope as the one queued last replaces
61+// it: the position is the latest and the relative deltas add up, so locked
62+// pointers (games) lose no movement. Nothing else is merged or reordered:
63+// buttons, focus, keys and gestures depend on their order.
64+func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, state ports.SecurityState, secured bool) {
65+ e := outCmd{cmd: v, state: state, secured: secured}
66+ e.motion, e.isMotion = v.(ports.PointerMotionTo)
67+ o.mu.Lock()
68+ if !o.busy && len(o.queue) == 0 {
69+ // A send that cannot block is safe under the lock.
70+ select {
71+ case out <- e.command():
72+ o.mu.Unlock()
73+ return
74+ default:
75+ }
76+ }
77+ if n := len(o.queue); n > 0 && e.isMotion {
78+ last := &o.queue[n-1]
79+ if last.isMotion && last.motion.ID == e.motion.ID && last.secured == secured && last.state == state {
80+ e.motion.DX += last.motion.DX
81+ e.motion.DY += last.motion.DY
82+ e.motion.UnaccelDX += last.motion.UnaccelDX
83+ e.motion.UnaccelDY += last.motion.UnaccelDY
84+ e.merged = true
85+ *last = e
86+ o.mu.Unlock()
87+ return
88+ }
89+ }
90+ o.queue = append(o.queue, e)
91+ o.mu.Unlock()
92+ select {
93+ case o.wake <- struct{}{}:
94+ default:
95+ }
96+}
97+
98+// take returns the queued commands and leaves an empty queue. The caller
99+// hands the batch back with recycle once it has sent it.
100+func (o *outbox) take() []outCmd {
101+ o.mu.Lock()
102+ batch := o.queue
103+ o.queue, o.spare = o.spare[:0], nil
104+ o.busy = len(batch) > 0
105+ o.mu.Unlock()
106+ return batch
107+}
108+
109+// recycle keeps the batch's storage for the next take.
110+func (o *outbox) recycle(batch []outCmd) {
111+ // Do not retain what the commands reference in the spare batch.
112+ clear(batch)
113+ o.mu.Lock()
114+ o.spare = batch[:0]
115+ o.busy = false
116+ o.mu.Unlock()
117+}
118+
119+// run delivers the queue to out in order until ctx ends. A command whose
120+// send is waiting is not merged any more, but what queues behind it is.
121+func (o *outbox) run(ctx context.Context, out chan<- ports.ClientCommand) {
122+ for {
123+ batch := o.take()
124+ if len(batch) == 0 {
125+ o.recycle(batch)
126+ select {
127+ case <-o.wake:
128+ case <-ctx.Done():
129+ return
130+ }
131+ continue
132+ }
133+ for i := range batch {
134+ select {
135+ case out <- batch[i].command():
136+ case <-ctx.Done():
137+ return
138+ }
139+ }
140+ o.recycle(batch)
141+ }
142+}
@@ -0,0 +1,142 @@
1+package core
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// outbox carries commands from the owner goroutine to the sender goroutine
11+// without ever blocking the owner. It is a transport queue, not window
12+// state: the mutex guards only the slices. Commands leave in the order they
13+// were queued; the only merge is of adjacent pointer motions (see push).
14+//
15+// The wayland display goroutine can be busy for a while. Sending from the
16+// owner would stall keyboard, scenes and frame wakes behind it, so the owner
17+// queues and the sender goroutine does the blocking send.
18+type outbox struct {
19+ // wake is signalled (never blocking) after each push.
20+ wake chan struct{}
21+
22+ mu sync.Mutex
23+ queue, spare []outCmd
24+ // busy is set while the sender holds a batch it has not finished
25+ // sending: a command pushed then must queue behind it.
26+ busy bool
27+}
28+
29+// outCmd is a queued command. A pointer motion is kept unboxed so that a
30+// merge boxes the sum once, when it leaves, not at every merge.
31+type outCmd struct {
32+ cmd ports.ClientCommand
33+ // state is the owner envelope; secured is false without a security gate.
34+ state ports.SecurityState
35+ secured bool
36+ // motion is cmd when it is a pointer motion. merged means cmd is stale
37+ // and motion carries the sum.
38+ motion ports.PointerMotionTo
39+ isMotion bool
40+ merged bool
41+}
42+
43+func newOutbox() outbox { return outbox{wake: make(chan struct{}, 1)} }
44+
45+// command returns what the sender delivers.
46+func (o *outCmd) command() ports.ClientCommand {
47+ v := o.cmd
48+ if o.merged {
49+ v = o.motion
50+ }
51+ if o.secured {
52+ return ports.SecurityCommand{State: o.state, Command: v}
53+ }
54+ return v
55+}
56+
57+// push delivers v, wrapped in an envelope for state when secured. With
58+// nothing queued or in flight and room in out it sends at once, which keeps
59+// the display's latency when it keeps up; otherwise it queues v. A pointer
60+// motion for the same window and envelope as the one queued last replaces
61+// it: the position is the latest and the relative deltas add up, so locked
62+// pointers (games) lose no movement. Nothing else is merged or reordered:
63+// buttons, focus, keys and gestures depend on their order.
64+func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, state ports.SecurityState, secured bool) {
65+ e := outCmd{cmd: v, state: state, secured: secured}
66+ e.motion, e.isMotion = v.(ports.PointerMotionTo)
67+ o.mu.Lock()
68+ if !o.busy && len(o.queue) == 0 {
69+ // A send that cannot block is safe under the lock.
70+ select {
71+ case out <- e.command():
72+ o.mu.Unlock()
73+ return
74+ default:
75+ }
76+ }
77+ if n := len(o.queue); n > 0 && e.isMotion {
78+ last := &o.queue[n-1]
79+ if last.isMotion && last.motion.ID == e.motion.ID && last.secured == secured && last.state == state {
80+ e.motion.DX += last.motion.DX
81+ e.motion.DY += last.motion.DY
82+ e.motion.UnaccelDX += last.motion.UnaccelDX
83+ e.motion.UnaccelDY += last.motion.UnaccelDY
84+ e.merged = true
85+ *last = e
86+ o.mu.Unlock()
87+ return
88+ }
89+ }
90+ o.queue = append(o.queue, e)
91+ o.mu.Unlock()
92+ select {
93+ case o.wake <- struct{}{}:
94+ default:
95+ }
96+}
97+
98+// take returns the queued commands and leaves an empty queue. The caller
99+// hands the batch back with recycle once it has sent it.
100+func (o *outbox) take() []outCmd {
101+ o.mu.Lock()
102+ batch := o.queue
103+ o.queue, o.spare = o.spare[:0], nil
104+ o.busy = len(batch) > 0
105+ o.mu.Unlock()
106+ return batch
107+}
108+
109+// recycle keeps the batch's storage for the next take.
110+func (o *outbox) recycle(batch []outCmd) {
111+ // Do not retain what the commands reference in the spare batch.
112+ clear(batch)
113+ o.mu.Lock()
114+ o.spare = batch[:0]
115+ o.busy = false
116+ o.mu.Unlock()
117+}
118+
119+// run delivers the queue to out in order until ctx ends. A command whose
120+// send is waiting is not merged any more, but what queues behind it is.
121+func (o *outbox) run(ctx context.Context, out chan<- ports.ClientCommand) {
122+ for {
123+ batch := o.take()
124+ if len(batch) == 0 {
125+ o.recycle(batch)
126+ select {
127+ case <-o.wake:
128+ case <-ctx.Done():
129+ return
130+ }
131+ continue
132+ }
133+ for i := range batch {
134+ select {
135+ case out <- batch[i].command():
136+ case <-ctx.Done():
137+ return
138+ }
139+ }
140+ o.recycle(batch)
141+ }
142+}
A internal/core/outbox_run_test.go +204 -0
@@ -0,0 +1,204 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outboxRig runs a core whose command channel nobody reads until the test
13+// does. Run and the sender live in a synctest bubble, so synctest.Wait
14+// returns once both are idle.
15+type outboxRig struct {
16+ input chan ports.InputEvent
17+ client chan ports.ClientEvent
18+ output chan ports.OutputEvent
19+ commands chan ports.ClientCommand
20+ scenes chan []ports.Scene
21+}
22+
23+func startOutboxRig(t *testing.T, commandCap int) *outboxRig {
24+ t.Helper()
25+ cfg := scrollDefaults()
26+ cfg.Border.Width = 0
27+ cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
28+ cfg.Binds["Cmd+minus"] = "scale-down"
29+ r := &outboxRig{
30+ input: make(chan ports.InputEvent),
31+ client: make(chan ports.ClientEvent),
32+ output: make(chan ports.OutputEvent),
33+ commands: make(chan ports.ClientCommand, commandCap),
34+ scenes: make(chan []ports.Scene, 1),
35+ }
36+ c, err := core.New(cfg, core.Channels{Input: r.input, Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes}, core.Options{})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ ctx, cancel := context.WithCancel(context.Background())
41+ done := make(chan error, 1)
42+ go func() { done <- c.Run(ctx) }()
43+ t.Cleanup(func() {
44+ cancel()
45+ // Run waits for its sender, so a sender stuck on the full channel
46+ // would keep this from returning.
47+ if err := receive(t, done); err != nil {
48+ t.Error(err)
49+ }
50+ })
51+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
52+ r.client <- ports.WindowMapped{ID: 1}
53+ synctest.Wait()
54+ // Settle: drop the scenes and the commands of the map.
55+ r.drain()
56+ return r
57+}
58+
59+// drain empties the scene and command channels and lets the sender refill
60+// them from its queue until nothing moves.
61+func (r *outboxRig) drain() []ports.ClientCommand {
62+ var got []ports.ClientCommand
63+ for {
64+ synctest.Wait()
65+ select {
66+ case <-r.scenes:
67+ default:
68+ }
69+ select {
70+ case v := <-r.commands:
71+ got = append(got, v)
72+ default:
73+ synctest.Wait()
74+ if len(r.commands) == 0 {
75+ return got
76+ }
77+ }
78+ }
79+}
80+
81+// A display that reads nothing must not stall core: motion keeps coming, and
82+// a bind after it still runs and publishes its scene.
83+func TestCommandsNeverBlockCore(t *testing.T) {
84+ synctest.Test(t, func(t *testing.T) {
85+ r := startOutboxRig(t, 32)
86+ const n = 10000
87+ for i := range n {
88+ // Each send returns only once Run is back in its select.
89+ r.input <- ports.PointerMotion{X: 500 + float64(i%100), Y: 200, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4}
90+ }
91+ r.input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
92+ synctest.Wait()
93+ select {
94+ case set := <-r.scenes:
95+ if set[0].Scale != 5.0/3 {
96+ t.Fatalf("scale %v, want 5/3", set[0].Scale)
97+ }
98+ default:
99+ t.Fatal("no scene: core is blocked on commands")
100+ }
101+ // Once the display reads again, it gets everything in order, with
102+ // the motion merged and no movement lost.
103+ var dx, dy, ux, uy float64
104+ var motions int
105+ var scaled bool
106+ for _, v := range r.drain() {
107+ switch v := v.(type) {
108+ case ports.PointerMotionTo:
109+ motions++
110+ dx, dy, ux, uy = dx+v.DX, dy+v.DY, ux+v.UnaccelDX, uy+v.UnaccelDY
111+ case ports.SetOutputs:
112+ scaled = v.Outputs[0].Scale == 5.0/3
113+ }
114+ }
115+ if dx != n || dy != 2*n || ux != 3*n || uy != 4*n {
116+ t.Fatalf("lost movement: %v %v %v %v", dx, dy, ux, uy)
117+ }
118+ if motions > 40 {
119+ t.Fatalf("%d motions reached the display, want them merged while it was away", motions)
120+ }
121+ if !scaled {
122+ t.Fatal("scale change not sent after the motions")
123+ }
124+ })
125+}
126+
127+// Commands reach the display in the order core made them. Only adjacent
128+// motions of one window merge.
129+func TestCommandsKeepOrderAndMergeAdjacentMotion(t *testing.T) {
130+ synctest.Test(t, func(t *testing.T) {
131+ r := startOutboxRig(t, 0)
132+ // No reader: the sender holds the first command, which is in flight
133+ // and no longer merges, and the rest queues behind it.
134+ motion := func(x, dx float64) {
135+ r.input <- ports.PointerMotion{X: x, Y: 200, DX: dx, UnaccelDX: dx}
136+ synctest.Wait()
137+ }
138+ // How the first commands split between the sender's batch and the
139+ // queue depends on when it wakes. The first key is a barrier: what
140+ // is pushed after the owner handled it never merges with what
141+ // came before, so the rest is deterministic.
142+ motion(500, 1)
143+ r.input <- ports.KeyEvent{Keysym: "y", Keycode: 21, Pressed: true}
144+ synctest.Wait()
145+ motion(501, 2)
146+ motion(502, 4)
147+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
148+ synctest.Wait()
149+ motion(503, 8)
150+ r.input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
151+ synctest.Wait()
152+ motion(504, 16)
153+ motion(505, 32)
154+ var got []ports.ClientCommand
155+ behind := false
156+ for _, v := range r.drainUnbuffered() {
157+ switch v.(type) {
158+ case ports.UserActivity, ports.SetOutputs:
159+ continue
160+ }
161+ if k, ok := v.(ports.ForwardKey); ok && k.Key.Keysym == "y" {
162+ behind = true
163+ continue
164+ }
165+ if behind {
166+ got = append(got, v)
167+ }
168+ }
169+ want := []ports.ClientCommand{
170+ ports.PointerMotionTo{ID: 1, X: 502, Y: 200, DX: 6, UnaccelDX: 6},
171+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true},
172+ ports.PointerMotionTo{ID: 1, X: 503, Y: 200, DX: 8, UnaccelDX: 8},
173+ ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}},
174+ ports.PointerMotionTo{ID: 1, X: 505, Y: 200, DX: 48, UnaccelDX: 48},
175+ }
176+ wantOrder(t, got, want)
177+ })
178+}
179+
180+// drainUnbuffered receives what the sender hands over one by one.
181+func (r *outboxRig) drainUnbuffered() []ports.ClientCommand {
182+ var got []ports.ClientCommand
183+ for {
184+ synctest.Wait()
185+ select {
186+ case v := <-r.commands:
187+ got = append(got, v)
188+ default:
189+ return got
190+ }
191+ }
192+}
193+
194+func wantOrder(t *testing.T, got, want []ports.ClientCommand) {
195+ t.Helper()
196+ if len(got) != len(want) {
197+ t.Fatalf("got %d commands, want %d:\n%#v", len(got), len(want), got)
198+ }
199+ for i := range want {
200+ if got[i] != want[i] {
201+ t.Errorf("command %d: got %#v, want %#v", i, got[i], want[i])
202+ }
203+ }
204+}
@@ -0,0 +1,204 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outboxRig runs a core whose command channel nobody reads until the test
13+// does. Run and the sender live in a synctest bubble, so synctest.Wait
14+// returns once both are idle.
15+type outboxRig struct {
16+ input chan ports.InputEvent
17+ client chan ports.ClientEvent
18+ output chan ports.OutputEvent
19+ commands chan ports.ClientCommand
20+ scenes chan []ports.Scene
21+}
22+
23+func startOutboxRig(t *testing.T, commandCap int) *outboxRig {
24+ t.Helper()
25+ cfg := scrollDefaults()
26+ cfg.Border.Width = 0
27+ cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
28+ cfg.Binds["Cmd+minus"] = "scale-down"
29+ r := &outboxRig{
30+ input: make(chan ports.InputEvent),
31+ client: make(chan ports.ClientEvent),
32+ output: make(chan ports.OutputEvent),
33+ commands: make(chan ports.ClientCommand, commandCap),
34+ scenes: make(chan []ports.Scene, 1),
35+ }
36+ c, err := core.New(cfg, core.Channels{Input: r.input, Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes}, core.Options{})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ ctx, cancel := context.WithCancel(context.Background())
41+ done := make(chan error, 1)
42+ go func() { done <- c.Run(ctx) }()
43+ t.Cleanup(func() {
44+ cancel()
45+ // Run waits for its sender, so a sender stuck on the full channel
46+ // would keep this from returning.
47+ if err := receive(t, done); err != nil {
48+ t.Error(err)
49+ }
50+ })
51+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
52+ r.client <- ports.WindowMapped{ID: 1}
53+ synctest.Wait()
54+ // Settle: drop the scenes and the commands of the map.
55+ r.drain()
56+ return r
57+}
58+
59+// drain empties the scene and command channels and lets the sender refill
60+// them from its queue until nothing moves.
61+func (r *outboxRig) drain() []ports.ClientCommand {
62+ var got []ports.ClientCommand
63+ for {
64+ synctest.Wait()
65+ select {
66+ case <-r.scenes:
67+ default:
68+ }
69+ select {
70+ case v := <-r.commands:
71+ got = append(got, v)
72+ default:
73+ synctest.Wait()
74+ if len(r.commands) == 0 {
75+ return got
76+ }
77+ }
78+ }
79+}
80+
81+// A display that reads nothing must not stall core: motion keeps coming, and
82+// a bind after it still runs and publishes its scene.
83+func TestCommandsNeverBlockCore(t *testing.T) {
84+ synctest.Test(t, func(t *testing.T) {
85+ r := startOutboxRig(t, 32)
86+ const n = 10000
87+ for i := range n {
88+ // Each send returns only once Run is back in its select.
89+ r.input <- ports.PointerMotion{X: 500 + float64(i%100), Y: 200, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4}
90+ }
91+ r.input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
92+ synctest.Wait()
93+ select {
94+ case set := <-r.scenes:
95+ if set[0].Scale != 5.0/3 {
96+ t.Fatalf("scale %v, want 5/3", set[0].Scale)
97+ }
98+ default:
99+ t.Fatal("no scene: core is blocked on commands")
100+ }
101+ // Once the display reads again, it gets everything in order, with
102+ // the motion merged and no movement lost.
103+ var dx, dy, ux, uy float64
104+ var motions int
105+ var scaled bool
106+ for _, v := range r.drain() {
107+ switch v := v.(type) {
108+ case ports.PointerMotionTo:
109+ motions++
110+ dx, dy, ux, uy = dx+v.DX, dy+v.DY, ux+v.UnaccelDX, uy+v.UnaccelDY
111+ case ports.SetOutputs:
112+ scaled = v.Outputs[0].Scale == 5.0/3
113+ }
114+ }
115+ if dx != n || dy != 2*n || ux != 3*n || uy != 4*n {
116+ t.Fatalf("lost movement: %v %v %v %v", dx, dy, ux, uy)
117+ }
118+ if motions > 40 {
119+ t.Fatalf("%d motions reached the display, want them merged while it was away", motions)
120+ }
121+ if !scaled {
122+ t.Fatal("scale change not sent after the motions")
123+ }
124+ })
125+}
126+
127+// Commands reach the display in the order core made them. Only adjacent
128+// motions of one window merge.
129+func TestCommandsKeepOrderAndMergeAdjacentMotion(t *testing.T) {
130+ synctest.Test(t, func(t *testing.T) {
131+ r := startOutboxRig(t, 0)
132+ // No reader: the sender holds the first command, which is in flight
133+ // and no longer merges, and the rest queues behind it.
134+ motion := func(x, dx float64) {
135+ r.input <- ports.PointerMotion{X: x, Y: 200, DX: dx, UnaccelDX: dx}
136+ synctest.Wait()
137+ }
138+ // How the first commands split between the sender's batch and the
139+ // queue depends on when it wakes. The first key is a barrier: what
140+ // is pushed after the owner handled it never merges with what
141+ // came before, so the rest is deterministic.
142+ motion(500, 1)
143+ r.input <- ports.KeyEvent{Keysym: "y", Keycode: 21, Pressed: true}
144+ synctest.Wait()
145+ motion(501, 2)
146+ motion(502, 4)
147+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
148+ synctest.Wait()
149+ motion(503, 8)
150+ r.input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
151+ synctest.Wait()
152+ motion(504, 16)
153+ motion(505, 32)
154+ var got []ports.ClientCommand
155+ behind := false
156+ for _, v := range r.drainUnbuffered() {
157+ switch v.(type) {
158+ case ports.UserActivity, ports.SetOutputs:
159+ continue
160+ }
161+ if k, ok := v.(ports.ForwardKey); ok && k.Key.Keysym == "y" {
162+ behind = true
163+ continue
164+ }
165+ if behind {
166+ got = append(got, v)
167+ }
168+ }
169+ want := []ports.ClientCommand{
170+ ports.PointerMotionTo{ID: 1, X: 502, Y: 200, DX: 6, UnaccelDX: 6},
171+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true},
172+ ports.PointerMotionTo{ID: 1, X: 503, Y: 200, DX: 8, UnaccelDX: 8},
173+ ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}},
174+ ports.PointerMotionTo{ID: 1, X: 505, Y: 200, DX: 48, UnaccelDX: 48},
175+ }
176+ wantOrder(t, got, want)
177+ })
178+}
179+
180+// drainUnbuffered receives what the sender hands over one by one.
181+func (r *outboxRig) drainUnbuffered() []ports.ClientCommand {
182+ var got []ports.ClientCommand
183+ for {
184+ synctest.Wait()
185+ select {
186+ case v := <-r.commands:
187+ got = append(got, v)
188+ default:
189+ return got
190+ }
191+ }
192+}
193+
194+func wantOrder(t *testing.T, got, want []ports.ClientCommand) {
195+ t.Helper()
196+ if len(got) != len(want) {
197+ t.Fatalf("got %d commands, want %d:\n%#v", len(got), len(want), got)
198+ }
199+ for i := range want {
200+ if got[i] != want[i] {
201+ t.Errorf("command %d: got %#v, want %#v", i, got[i], want[i])
202+ }
203+ }
204+}
A internal/core/outbox_test.go +81 -0
@@ -0,0 +1,81 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func wantOutbox(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
11+ t.Helper()
12+ if !slices.Equal(got, want) {
13+ t.Fatalf("commands %#v, want %#v", got, want)
14+ }
15+}
16+
17+// takeAll returns what the sender would deliver, in order.
18+func takeAll(o *outbox) []ports.ClientCommand {
19+ var got []ports.ClientCommand
20+ for _, e := range o.take() {
21+ got = append(got, e.command())
22+ }
23+ return got
24+}
25+
26+// Only adjacent motions for the same window merge: the position is the
27+// latest, the deltas add up. Everything else keeps its place.
28+func TestOutboxMergesAdjacentMotionOnly(t *testing.T) {
29+ o := newOutbox()
30+ // Nobody receives, so every command queues.
31+ out := make(chan ports.ClientCommand)
32+ push := func(v ports.ClientCommand) { o.push(out, v, ports.SecurityState{}, false) }
33+ push(ports.PointerMotionTo{ID: 1, X: 1, Y: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
34+ push(ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
35+ push(ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true})
36+ push(ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5})
37+ push(ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7})
38+ push(ports.FocusWindow{ID: 2})
39+ push(ports.PointerMotionTo{ID: 2, X: 5, Y: 5, DX: 1})
40+ push(ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 1})
41+ want := []ports.ClientCommand{
42+ ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44},
43+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true},
44+ ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5},
45+ ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7},
46+ ports.FocusWindow{ID: 2},
47+ ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 2},
48+ }
49+ wantOutbox(t, takeAll(&o), want...)
50+}
51+
52+// Wrapped motions merge only under an identical owner envelope, and keep it.
53+func TestOutboxMergesSecuredMotionPerEnvelope(t *testing.T) {
54+ o := newOutbox()
55+ out := make(chan ports.ClientCommand)
56+ a := ports.SecurityState{Generation: 1}
57+ b := ports.SecurityState{Generation: 2}
58+ o.push(out, ports.PointerMotionTo{ID: 1, X: 1, DX: 1}, a, true)
59+ o.push(out, ports.PointerMotionTo{ID: 1, X: 2, DX: 2}, a, true)
60+ o.push(out, ports.PointerMotionTo{ID: 1, X: 3, DX: 4}, b, true)
61+ o.push(out, ports.PointerMotionTo{ID: 1, X: 4, DX: 8}, ports.SecurityState{}, false)
62+ wantOutbox(t, takeAll(&o),
63+ ports.SecurityCommand{State: a, Command: ports.PointerMotionTo{ID: 1, X: 2, DX: 3}},
64+ ports.SecurityCommand{State: b, Command: ports.PointerMotionTo{ID: 1, X: 3, DX: 4}},
65+ ports.PointerMotionTo{ID: 1, X: 4, DX: 8},
66+ )
67+}
68+
69+// With room in the channel and nothing queued, a command goes straight out.
70+func TestOutboxSendsDirectlyWhileDisplayKeepsUp(t *testing.T) {
71+ o := newOutbox()
72+ out := make(chan ports.ClientCommand, 4)
73+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, ports.SecurityState{}, false)
74+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, ports.SecurityState{}, false)
75+ if len(out) != 2 {
76+ t.Fatalf("%d commands sent, want 2 unmerged", len(out))
77+ }
78+ if got := o.take(); len(got) != 0 {
79+ t.Fatalf("queued %d", len(got))
80+ }
81+}
@@ -0,0 +1,81 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func wantOutbox(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
11+ t.Helper()
12+ if !slices.Equal(got, want) {
13+ t.Fatalf("commands %#v, want %#v", got, want)
14+ }
15+}
16+
17+// takeAll returns what the sender would deliver, in order.
18+func takeAll(o *outbox) []ports.ClientCommand {
19+ var got []ports.ClientCommand
20+ for _, e := range o.take() {
21+ got = append(got, e.command())
22+ }
23+ return got
24+}
25+
26+// Only adjacent motions for the same window merge: the position is the
27+// latest, the deltas add up. Everything else keeps its place.
28+func TestOutboxMergesAdjacentMotionOnly(t *testing.T) {
29+ o := newOutbox()
30+ // Nobody receives, so every command queues.
31+ out := make(chan ports.ClientCommand)
32+ push := func(v ports.ClientCommand) { o.push(out, v, ports.SecurityState{}, false) }
33+ push(ports.PointerMotionTo{ID: 1, X: 1, Y: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
34+ push(ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
35+ push(ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true})
36+ push(ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5})
37+ push(ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7})
38+ push(ports.FocusWindow{ID: 2})
39+ push(ports.PointerMotionTo{ID: 2, X: 5, Y: 5, DX: 1})
40+ push(ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 1})
41+ want := []ports.ClientCommand{
42+ ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44},
43+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true},
44+ ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5},
45+ ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7},
46+ ports.FocusWindow{ID: 2},
47+ ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 2},
48+ }
49+ wantOutbox(t, takeAll(&o), want...)
50+}
51+
52+// Wrapped motions merge only under an identical owner envelope, and keep it.
53+func TestOutboxMergesSecuredMotionPerEnvelope(t *testing.T) {
54+ o := newOutbox()
55+ out := make(chan ports.ClientCommand)
56+ a := ports.SecurityState{Generation: 1}
57+ b := ports.SecurityState{Generation: 2}
58+ o.push(out, ports.PointerMotionTo{ID: 1, X: 1, DX: 1}, a, true)
59+ o.push(out, ports.PointerMotionTo{ID: 1, X: 2, DX: 2}, a, true)
60+ o.push(out, ports.PointerMotionTo{ID: 1, X: 3, DX: 4}, b, true)
61+ o.push(out, ports.PointerMotionTo{ID: 1, X: 4, DX: 8}, ports.SecurityState{}, false)
62+ wantOutbox(t, takeAll(&o),
63+ ports.SecurityCommand{State: a, Command: ports.PointerMotionTo{ID: 1, X: 2, DX: 3}},
64+ ports.SecurityCommand{State: b, Command: ports.PointerMotionTo{ID: 1, X: 3, DX: 4}},
65+ ports.PointerMotionTo{ID: 1, X: 4, DX: 8},
66+ )
67+}
68+
69+// With room in the channel and nothing queued, a command goes straight out.
70+func TestOutboxSendsDirectlyWhileDisplayKeepsUp(t *testing.T) {
71+ o := newOutbox()
72+ out := make(chan ports.ClientCommand, 4)
73+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, ports.SecurityState{}, false)
74+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, ports.SecurityState{}, false)
75+ if len(out) != 2 {
76+ t.Fatalf("%d commands sent, want 2 unmerged", len(out))
77+ }
78+ if got := o.take(); len(got) != 0 {
79+ t.Fatalf("queued %d", len(got))
80+ }
81+}
M internal/core/security_review_test.go +15 -24
@@ -29,28 +29,25 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
2929 var state atomic.Value
3030 state.Store(ports.SecurityState{})
3131 gate := portsmocks.NewMockSessionSecurity(t)
32- entered := make(chan struct{}, 1)
3332 var calls atomic.Int32
3433 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
3534 v := state.Load().(ports.SecurityState)
3635 if calls.Add(1) == 2 {
37- entered <- struct{}{}
36+ // The epoch changes right after the check that admits the command.
37+ state.Store(ports.SecurityState{Generation: 2})
3838 }
3939 return v
4040 })
4141 c.opts.Security = gate
4242 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43- commands := make(chan ports.ClientCommand)
43+ commands := make(chan ports.ClientCommand, 8)
4444 c.ch.Commands = commands
45- done := make(chan error, 1)
46- go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }()
47- reviewReceive(t, entered)
48- state.Store(ports.SecurityState{Generation: 2})
45+ err := c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30})
4946 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
5047 if cmd.State != (ports.SecurityState{}) {
5148 t.Fatalf("old handler adopted new epoch: %+v", cmd)
5249 }
53- if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) {
50+ if !errors.Is(err, errSecurityChanged) {
5451 t.Fatalf("handler not aborted: %v", err)
5552 }
5653 if c.security != (ports.SecurityState{}) {
@@ -88,29 +85,26 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
8885 var state atomic.Value
8986 state.Store(ports.SecurityState{})
9087 gate := portsmocks.NewMockSessionSecurity(t)
91- checked := make(chan struct{}, 1)
9288 var calls atomic.Int32
9389 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
9490 v := state.Load().(ports.SecurityState)
9591 if calls.Add(1) == 2 {
96- checked <- struct{}{}
92+ // The gate locks right after the check that admits the slot.
93+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
9794 }
9895 return v
9996 })
10097 c.opts.Security = gate
101- commands := make(chan ports.ClientCommand)
98+ commands := make(chan ports.ClientCommand, 8)
10299 c.ch.Commands = commands
103100 spawn := make(chan ports.SpawnRequest, 2)
104101 c.ch.Spawn = spawn
105- done := make(chan error, 1)
106- go func() { done <- c.spawnSlots(context.Background(), false) }()
107- reviewReceive(t, checked)
108- state.Store(ports.SecurityState{Generation: 1, Protected: true})
102+ err := c.spawnSlots(context.Background(), false)
109103 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
110104 if _, ok := cmd.Command.(ports.SlotsPending); !ok {
111105 t.Fatal(cmd)
112106 }
113- if err := reviewReceive(t, done); err != nil {
107+ if err != nil {
114108 t.Fatal(err)
115109 }
116110 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
@@ -138,26 +132,23 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
138132 var state atomic.Value
139133 state.Store(ports.SecurityState{})
140134 gate := portsmocks.NewMockSessionSecurity(t)
141- checked := make(chan struct{}, 1)
142135 var calls atomic.Int32
143136 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
144137 v := state.Load().(ports.SecurityState)
145138 if calls.Add(1) == 1 {
146- checked <- struct{}{}
139+ // The gate locks right after the check that admits the spawn.
140+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
147141 }
148142 return v
149143 })
150144 c.opts.Security = gate
151- commands := make(chan ports.ClientCommand)
145+ commands := make(chan ports.ClientCommand, 8)
152146 c.ch.Commands = commands
153147 spawn := make(chan ports.SpawnRequest, 1)
154148 c.ch.Spawn = spawn
155- done := make(chan error, 1)
156- go func() { done <- c.spawnEmpty(context.Background()) }()
157- reviewReceive(t, checked)
158- state.Store(ports.SecurityState{Generation: 1, Protected: true})
149+ err := c.spawnEmpty(context.Background())
159150 reviewReceive(t, commands)
160- if err := reviewReceive(t, done); err != nil {
151+ if err != nil {
161152 t.Fatal(err)
162153 }
163154 if len(spawn) != 0 || c.placement.anyPending() {
@@ -29,28 +29,25 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
29 var state atomic.Value29 var state atomic.Value
30 state.Store(ports.SecurityState{})30 state.Store(ports.SecurityState{})
31 gate := portsmocks.NewMockSessionSecurity(t)31 gate := portsmocks.NewMockSessionSecurity(t)
32- entered := make(chan struct{}, 1)
33 var calls atomic.Int3232 var calls atomic.Int32
34 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {33 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
35 v := state.Load().(ports.SecurityState)34 v := state.Load().(ports.SecurityState)
36 if calls.Add(1) == 2 {35 if calls.Add(1) == 2 {
37- entered <- struct{}{}36+ // The epoch changes right after the check that admits the command.
37+ state.Store(ports.SecurityState{Generation: 2})
38 }38 }
39 return v39 return v
40 })40 })
41 c.opts.Security = gate41 c.opts.Security = gate
42 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}42 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43- commands := make(chan ports.ClientCommand)43+ commands := make(chan ports.ClientCommand, 8)
44 c.ch.Commands = commands44 c.ch.Commands = commands
45- done := make(chan error, 1)45+ err := c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30})
46- go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }()
47- reviewReceive(t, entered)
48- state.Store(ports.SecurityState{Generation: 2})
49 cmd := reviewReceive(t, commands).(ports.SecurityCommand)46 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
50 if cmd.State != (ports.SecurityState{}) {47 if cmd.State != (ports.SecurityState{}) {
51 t.Fatalf("old handler adopted new epoch: %+v", cmd)48 t.Fatalf("old handler adopted new epoch: %+v", cmd)
52 }49 }
53- if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) {50+ if !errors.Is(err, errSecurityChanged) {
54 t.Fatalf("handler not aborted: %v", err)51 t.Fatalf("handler not aborted: %v", err)
55 }52 }
56 if c.security != (ports.SecurityState{}) {53 if c.security != (ports.SecurityState{}) {
@@ -88,29 +85,26 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
88 var state atomic.Value85 var state atomic.Value
89 state.Store(ports.SecurityState{})86 state.Store(ports.SecurityState{})
90 gate := portsmocks.NewMockSessionSecurity(t)87 gate := portsmocks.NewMockSessionSecurity(t)
91- checked := make(chan struct{}, 1)
92 var calls atomic.Int3288 var calls atomic.Int32
93 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {89 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
94 v := state.Load().(ports.SecurityState)90 v := state.Load().(ports.SecurityState)
95 if calls.Add(1) == 2 {91 if calls.Add(1) == 2 {
96- checked <- struct{}{}92+ // The gate locks right after the check that admits the slot.
93+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
97 }94 }
98 return v95 return v
99 })96 })
100 c.opts.Security = gate97 c.opts.Security = gate
101- commands := make(chan ports.ClientCommand)98+ commands := make(chan ports.ClientCommand, 8)
102 c.ch.Commands = commands99 c.ch.Commands = commands
103 spawn := make(chan ports.SpawnRequest, 2)100 spawn := make(chan ports.SpawnRequest, 2)
104 c.ch.Spawn = spawn101 c.ch.Spawn = spawn
105- done := make(chan error, 1)102+ err := c.spawnSlots(context.Background(), false)
106- go func() { done <- c.spawnSlots(context.Background(), false) }()
107- reviewReceive(t, checked)
108- state.Store(ports.SecurityState{Generation: 1, Protected: true})
109 cmd := reviewReceive(t, commands).(ports.SecurityCommand)103 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
110 if _, ok := cmd.Command.(ports.SlotsPending); !ok {104 if _, ok := cmd.Command.(ports.SlotsPending); !ok {
111 t.Fatal(cmd)105 t.Fatal(cmd)
112 }106 }
113- if err := reviewReceive(t, done); err != nil {107+ if err != nil {
114 t.Fatal(err)108 t.Fatal(err)
115 }109 }
116 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {110 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
@@ -138,26 +132,23 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
138 var state atomic.Value132 var state atomic.Value
139 state.Store(ports.SecurityState{})133 state.Store(ports.SecurityState{})
140 gate := portsmocks.NewMockSessionSecurity(t)134 gate := portsmocks.NewMockSessionSecurity(t)
141- checked := make(chan struct{}, 1)
142 var calls atomic.Int32135 var calls atomic.Int32
143 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {136 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
144 v := state.Load().(ports.SecurityState)137 v := state.Load().(ports.SecurityState)
145 if calls.Add(1) == 1 {138 if calls.Add(1) == 1 {
146- checked <- struct{}{}139+ // The gate locks right after the check that admits the spawn.
140+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
147 }141 }
148 return v142 return v
149 })143 })
150 c.opts.Security = gate144 c.opts.Security = gate
151- commands := make(chan ports.ClientCommand)145+ commands := make(chan ports.ClientCommand, 8)
152 c.ch.Commands = commands146 c.ch.Commands = commands
153 spawn := make(chan ports.SpawnRequest, 1)147 spawn := make(chan ports.SpawnRequest, 1)
154 c.ch.Spawn = spawn148 c.ch.Spawn = spawn
155- done := make(chan error, 1)149+ err := c.spawnEmpty(context.Background())
156- go func() { done <- c.spawnEmpty(context.Background()) }()
157- reviewReceive(t, checked)
158- state.Store(ports.SecurityState{Generation: 1, Protected: true})
159 reviewReceive(t, commands)150 reviewReceive(t, commands)
160- if err := reviewReceive(t, done); err != nil {151+ if err != nil {
161 t.Fatal(err)152 t.Fatal(err)
162 }153 }
163 if len(spawn) != 0 || c.placement.anyPending() {154 if len(spawn) != 0 || c.placement.anyPending() {
M internal/core/stash_spawn_internal_test.go +20 -32
@@ -5,7 +5,6 @@ import (
55 "errors"
66 "strings"
77 "testing"
8- "testing/synctest"
98 "time"
109
1110 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
@@ -41,39 +40,28 @@ func claimToken(req ports.SpawnRequest) string {
4140 }
4241
4342 // Wayland reads tokens only while slots are pending, so the flag is sent
44-// before the spawn can reach the launcher. In a synctest bubble the worker
45-// is waited on until it blocks sending the flag on the unread command
46-// channel: the spawn channel must still be empty then.
43+// before the spawn can reach the launcher. While the display keeps up the
44+// flag is in the command channel when the spawn is handed over.
4745 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48- synctest.Test(t, func(t *testing.T) {
49- now := claimBase
50- commands := make(chan ports.ClientCommand)
51- spawn := make(chan ports.SpawnRequest, 1)
52- c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53- ctx, cancel := context.WithCancel(context.Background())
54- t.Cleanup(cancel)
55- done := make(chan error, 1)
56- go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57- synctest.Wait()
58- if len(spawn) != 0 {
59- t.Fatal("spawned before the pending flag was delivered")
60- }
61- select {
62- case err := <-done:
63- t.Fatalf("spawnStash returned before the flag was read: %v", err)
64- default:
65- }
66- if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67- t.Fatal("first command is not SlotsPending{true}")
68- }
69- synctest.Wait()
70- if err := <-done; err != nil {
71- t.Fatal(err)
72- }
73- if len(spawn) != 1 {
74- t.Fatal("no spawn after the flag")
46+ now := claimBase
47+ commands := make(chan ports.ClientCommand, 8)
48+ spawn := make(chan ports.SpawnRequest, 1)
49+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
50+ spawned := make(chan struct{})
51+ go func() {
52+ <-spawn
53+ if len(commands) != 1 {
54+ t.Error("spawned before the pending flag was delivered")
7555 }
76- })
56+ close(spawned)
57+ }()
58+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false); err != nil {
59+ t.Fatal(err)
60+ }
61+ <-spawned
62+ if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
63+ t.Fatal("first command is not SlotsPending{true}")
64+ }
7765 }
7866
7967 // A send the launcher queue suppresses leaves no claim behind, and the
@@ -5,7 +5,6 @@ import (
5 "errors"5 "errors"
6 "strings"6 "strings"
7 "testing"7 "testing"
8- "testing/synctest"
9 "time"8 "time"
10 9
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"10 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
@@ -41,39 +40,28 @@ func claimToken(req ports.SpawnRequest) string {
41 }40 }
42 41
43 // Wayland reads tokens only while slots are pending, so the flag is sent42 // Wayland reads tokens only while slots are pending, so the flag is sent
44-// before the spawn can reach the launcher. In a synctest bubble the worker43+// before the spawn can reach the launcher. While the display keeps up the
45-// is waited on until it blocks sending the flag on the unread command44+// flag is in the command channel when the spawn is handed over.
46-// channel: the spawn channel must still be empty then.
47 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {45 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48- synctest.Test(t, func(t *testing.T) {46+ now := claimBase
49- now := claimBase47+ commands := make(chan ports.ClientCommand, 8)
50- commands := make(chan ports.ClientCommand)48+ spawn := make(chan ports.SpawnRequest, 1)
51- spawn := make(chan ports.SpawnRequest, 1)49+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
52- c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))50+ spawned := make(chan struct{})
53- ctx, cancel := context.WithCancel(context.Background())51+ go func() {
54- t.Cleanup(cancel)52+ <-spawn
55- done := make(chan error, 1)53+ if len(commands) != 1 {
56- go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()54+ t.Error("spawned before the pending flag was delivered")
57- synctest.Wait()
58- if len(spawn) != 0 {
59- t.Fatal("spawned before the pending flag was delivered")
60- }
61- select {
62- case err := <-done:
63- t.Fatalf("spawnStash returned before the flag was read: %v", err)
64- default:
65- }
66- if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67- t.Fatal("first command is not SlotsPending{true}")
68- }
69- synctest.Wait()
70- if err := <-done; err != nil {
71- t.Fatal(err)
72- }
73- if len(spawn) != 1 {
74- t.Fatal("no spawn after the flag")
75 }55 }
76- })56+ close(spawned)
57+ }()
58+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false); err != nil {
59+ t.Fatal(err)
60+ }
61+ <-spawned
62+ if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
63+ t.Fatal("first command is not SlotsPending{true}")
64+ }
77 }65 }
78 66
79 // A send the launcher queue suppresses leaves no claim behind, and the67 // A send the launcher queue suppresses leaves no claim behind, and the

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