detached a6a7bd5

Merge d5d679d9bbf4d05853071f869a5fc768109095c6 into e11014e0c375e204529f6347f5d2f6c70c32d12b

commit
a6a7bd5dbd7f44ef071dbd13b536c00cc816e8b7
author
ermite · · 2026-10-19 19:14 UTC
merge of
e11014e + d5d679d · diff against the first parent
signature
unsigned
34 files · +781 -331
M .mockery.yml +5 -0
@@ -113,6 +113,11 @@ packages:
113113 filename: proc_tree_mock_test.go
114114 github.com/bnema/neferwl/internal/adapters/hints:
115115 interfaces:
116+ languageRunner:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: language_runner_mock_test.go
116121 targetSource:
117122 config:
118123 dir: internal/adapters/hints
@@ -113,6 +113,11 @@ packages:
113 filename: proc_tree_mock_test.go113 filename: proc_tree_mock_test.go
114 github.com/bnema/neferwl/internal/adapters/hints:114 github.com/bnema/neferwl/internal/adapters/hints:
115 interfaces:115 interfaces:
116+ languageRunner:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: language_runner_mock_test.go
116 targetSource:121 targetSource:
117 config:122 config:
118 dir: internal/adapters/hints123 dir: internal/adapters/hints
M docs/config.md +2 -1
@@ -335,7 +335,8 @@ small or low-contrast text.
335335
336336 ### Requirements
337337
338-- Accessibility: NeferWL turns it on at startup. Some apps need a flag:
338+- Accessibility: NeferWL enables it lazily on the first hint scan that queries
339+ app windows. Apps started before then may need restarting. Some apps need a flag:
339340 - Chromium: `--force-renderer-accessibility`
340341 - Electron: `ACCESSIBILITY_ENABLED=1`
341342 - Qt: `QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1`
@@ -335,7 +335,8 @@ small or low-contrast text.
335 335
336 ### Requirements336 ### Requirements
337 337
338-- Accessibility: NeferWL turns it on at startup. Some apps need a flag:338+- Accessibility: NeferWL enables it lazily on the first hint scan that queries
339+ app windows. Apps started before then may need restarting. Some apps need a flag:
339 - Chromium: `--force-renderer-accessibility`340 - Chromium: `--force-renderer-accessibility`
340 - Electron: `ACCESSIBILITY_ENABLED=1`341 - Electron: `ACCESSIBILITY_ENABLED=1`
341 - Qt: `QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1`342 - Qt: `QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1`
M internal/adapters/atspi/source.go +4 -5
@@ -459,7 +459,8 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
459459 call{op: opState, name: n.name, path: n.path})
460460 }
461461 rep := do(calls)
462- var next []ref
462+ room := max(0, maxWalkNodes-seen-len(level))
463+ next := make([]ref, 0, room)
463464 for i, n := range level {
464465 ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]
465466 if state.err == nil && !hasState(state.state, stateShowing) {
@@ -469,13 +470,11 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
469470 out = append(out, n)
470471 }
471472 if ch.err == nil {
472- next = append(next, ch.refs...)
473+ take := min(len(ch.refs), room-len(next))
474+ next = append(next, ch.refs[:take]...)
473475 }
474476 }
475477 seen += len(level)
476- if room := maxWalkNodes - seen; len(next) > room {
477- next = next[:max(room, 0)]
478- }
479478 level = next
480479 }
481480 return out
@@ -459,7 +459,8 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
459 call{op: opState, name: n.name, path: n.path})459 call{op: opState, name: n.name, path: n.path})
460 }460 }
461 rep := do(calls)461 rep := do(calls)
462- var next []ref462+ room := max(0, maxWalkNodes-seen-len(level))
463+ next := make([]ref, 0, room)
463 for i, n := range level {464 for i, n := range level {
464 ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]465 ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]
465 if state.err == nil && !hasState(state.state, stateShowing) {466 if state.err == nil && !hasState(state.state, stateShowing) {
@@ -469,13 +470,11 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
469 out = append(out, n)470 out = append(out, n)
470 }471 }
471 if ch.err == nil {472 if ch.err == nil {
472- next = append(next, ch.refs...)473+ take := min(len(ch.refs), room-len(next))
474+ next = append(next, ch.refs[:take]...)
473 }475 }
474 }476 }
475 seen += len(level)477 seen += len(level)
476- if room := maxWalkNodes - seen; len(next) > room {
477- next = next[:max(room, 0)]
478- }
479 level = next478 level = next
480 }479 }
481 return out480 return out
M internal/adapters/atspi/source_test.go +40 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "runtime"
78 "testing"
89 "time"
910
@@ -313,3 +314,42 @@ func TestCloseReapsLateTransport(t *testing.T) {
313314 require.Empty(t, covered)
314315 require.NoError(t, s.Close()) // idempotent
315316 }
317+
318+func TestWalkWideTreeStaysWithinBudget(t *testing.T) {
319+ tr := newMocktransport(t)
320+ children := make([]ref, 4096)
321+ for i := range children {
322+ children[i] = ref{":1.5", fmt.Sprintf("/node%d", i)}
323+ }
324+ visited := 0
325+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
326+ func(_ time.Time, _ time.Duration, calls []call) []reply {
327+ visited += len(calls) / 3
328+ require.LessOrEqual(t, visited, maxWalkNodes)
329+ rep := make([]reply, len(calls))
330+ for i, c := range calls {
331+ switch c.op {
332+ case opChildren:
333+ rep[i].refs = children
334+ case opRole:
335+ rep[i].u = roleLink
336+ case opState:
337+ rep[i].state = stShowing | 1<<stateVisible
338+ }
339+ }
340+ return rep
341+ }).Twice()
342+ s := &Source{tr: tr}
343+ // A full second level replies with another 4096 children each. There is
344+ // no remaining budget, so none of those millions of refs may be copied.
345+ var out []ref
346+ var before, after runtime.MemStats
347+ runtime.ReadMemStats(&before)
348+ out = s.walk(func(calls []call) []reply { return tr.Do(time.Time{}, time.Second, calls) }, ref{":1.5", "/frame"}, false)
349+ runtime.ReadMemStats(&after)
350+ require.Len(t, out, maxWalkNodes)
351+ require.Equal(t, maxWalkNodes, visited)
352+ // Normal replies and call batches take only a few MB; the old append-
353+ // then-truncate path copied hundreds of MB for this tree.
354+ require.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(32<<20))
355+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "runtime"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
@@ -313,3 +314,42 @@ func TestCloseReapsLateTransport(t *testing.T) {
313 require.Empty(t, covered)314 require.Empty(t, covered)
314 require.NoError(t, s.Close()) // idempotent315 require.NoError(t, s.Close()) // idempotent
315 }316 }
317+
318+func TestWalkWideTreeStaysWithinBudget(t *testing.T) {
319+ tr := newMocktransport(t)
320+ children := make([]ref, 4096)
321+ for i := range children {
322+ children[i] = ref{":1.5", fmt.Sprintf("/node%d", i)}
323+ }
324+ visited := 0
325+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
326+ func(_ time.Time, _ time.Duration, calls []call) []reply {
327+ visited += len(calls) / 3
328+ require.LessOrEqual(t, visited, maxWalkNodes)
329+ rep := make([]reply, len(calls))
330+ for i, c := range calls {
331+ switch c.op {
332+ case opChildren:
333+ rep[i].refs = children
334+ case opRole:
335+ rep[i].u = roleLink
336+ case opState:
337+ rep[i].state = stShowing | 1<<stateVisible
338+ }
339+ }
340+ return rep
341+ }).Twice()
342+ s := &Source{tr: tr}
343+ // A full second level replies with another 4096 children each. There is
344+ // no remaining budget, so none of those millions of refs may be copied.
345+ var out []ref
346+ var before, after runtime.MemStats
347+ runtime.ReadMemStats(&before)
348+ out = s.walk(func(calls []call) []reply { return tr.Do(time.Time{}, time.Second, calls) }, ref{":1.5", "/frame"}, false)
349+ runtime.ReadMemStats(&after)
350+ require.Len(t, out, maxWalkNodes)
351+ require.Equal(t, maxWalkNodes, visited)
352+ // Normal replies and call batches take only a few MB; the old append-
353+ // then-truncate path copied hundreds of MB for this tree.
354+ require.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(32<<20))
355+}
A internal/adapters/hints/language_runner_mock_test.go +130 -0
@@ -0,0 +1,130 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package hints
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocklanguageRunner creates a new instance of mocklanguageRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocklanguageRunner(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocklanguageRunner {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocklanguageRunner{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocklanguageRunner is an autogenerated mock type for the languageRunner type
37+type mocklanguageRunner struct {
38+ mock.Mock
39+}
40+
41+type mocklanguageRunner_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocklanguageRunner) EXPECT() *mocklanguageRunner_Expecter {
46+ return &mocklanguageRunner_Expecter{mock: &_m.Mock}
47+}
48+
49+// Output provides a mock function for the type mocklanguageRunner
50+func (_mock *mocklanguageRunner) Output(context1 context.Context, s string, strings ...string) ([]byte, error) {
51+ var tmpRet mock.Arguments
52+ if len(strings) > 0 {
53+ tmpRet = _mock.Called(context1, s, strings)
54+ } else {
55+ tmpRet = _mock.Called(context1, s)
56+ }
57+ ret := tmpRet
58+
59+ if len(ret) == 0 {
60+ panic("no return value specified for Output")
61+ }
62+
63+ var r0 []byte
64+ var r1 error
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) ([]byte, error)); ok {
66+ return returnFunc(context1, s, strings...)
67+ }
68+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) []byte); ok {
69+ r0 = returnFunc(context1, s, strings...)
70+ } else {
71+ if ret.Get(0) != nil {
72+ r0 = ret.Get(0).([]byte)
73+ }
74+ }
75+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, ...string) error); ok {
76+ r1 = returnFunc(context1, s, strings...)
77+ } else {
78+ r1 = ret.Error(1)
79+ }
80+ return r0, r1
81+}
82+
83+// mocklanguageRunner_Output_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Output'
84+type mocklanguageRunner_Output_Call struct {
85+ *mock.Call
86+}
87+
88+// Output is a helper method to define mock.On call
89+// - context1 context.Context
90+// - s string
91+// - strings ...string
92+func (_e *mocklanguageRunner_Expecter) Output(context1 any, s any, strings ...any) *mocklanguageRunner_Output_Call {
93+ return &mocklanguageRunner_Output_Call{Call: _e.mock.On("Output",
94+ append([]any{context1, s}, strings...)...)}
95+}
96+
97+func (_c *mocklanguageRunner_Output_Call) Run(run func(context1 context.Context, s string, strings ...string)) *mocklanguageRunner_Output_Call {
98+ _c.Call.Run(func(args mock.Arguments) {
99+ var arg0 context.Context
100+ if args[0] != nil {
101+ arg0 = args[0].(context.Context)
102+ }
103+ var arg1 string
104+ if args[1] != nil {
105+ arg1 = args[1].(string)
106+ }
107+ var arg2 []string
108+ var variadicArgs []string
109+ if len(args) > 2 {
110+ variadicArgs = args[2].([]string)
111+ }
112+ arg2 = variadicArgs
113+ run(
114+ arg0,
115+ arg1,
116+ arg2...,
117+ )
118+ })
119+ return _c
120+}
121+
122+func (_c *mocklanguageRunner_Output_Call) Return(bytes []byte, err error) *mocklanguageRunner_Output_Call {
123+ _c.Call.Return(bytes, err)
124+ return _c
125+}
126+
127+func (_c *mocklanguageRunner_Output_Call) RunAndReturn(run func(context1 context.Context, s string, strings ...string) ([]byte, error)) *mocklanguageRunner_Output_Call {
128+ _c.Call.Return(run)
129+ return _c
130+}
@@ -0,0 +1,130 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package hints
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocklanguageRunner creates a new instance of mocklanguageRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocklanguageRunner(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocklanguageRunner {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocklanguageRunner{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocklanguageRunner is an autogenerated mock type for the languageRunner type
37+type mocklanguageRunner struct {
38+ mock.Mock
39+}
40+
41+type mocklanguageRunner_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocklanguageRunner) EXPECT() *mocklanguageRunner_Expecter {
46+ return &mocklanguageRunner_Expecter{mock: &_m.Mock}
47+}
48+
49+// Output provides a mock function for the type mocklanguageRunner
50+func (_mock *mocklanguageRunner) Output(context1 context.Context, s string, strings ...string) ([]byte, error) {
51+ var tmpRet mock.Arguments
52+ if len(strings) > 0 {
53+ tmpRet = _mock.Called(context1, s, strings)
54+ } else {
55+ tmpRet = _mock.Called(context1, s)
56+ }
57+ ret := tmpRet
58+
59+ if len(ret) == 0 {
60+ panic("no return value specified for Output")
61+ }
62+
63+ var r0 []byte
64+ var r1 error
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) ([]byte, error)); ok {
66+ return returnFunc(context1, s, strings...)
67+ }
68+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) []byte); ok {
69+ r0 = returnFunc(context1, s, strings...)
70+ } else {
71+ if ret.Get(0) != nil {
72+ r0 = ret.Get(0).([]byte)
73+ }
74+ }
75+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, ...string) error); ok {
76+ r1 = returnFunc(context1, s, strings...)
77+ } else {
78+ r1 = ret.Error(1)
79+ }
80+ return r0, r1
81+}
82+
83+// mocklanguageRunner_Output_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Output'
84+type mocklanguageRunner_Output_Call struct {
85+ *mock.Call
86+}
87+
88+// Output is a helper method to define mock.On call
89+// - context1 context.Context
90+// - s string
91+// - strings ...string
92+func (_e *mocklanguageRunner_Expecter) Output(context1 any, s any, strings ...any) *mocklanguageRunner_Output_Call {
93+ return &mocklanguageRunner_Output_Call{Call: _e.mock.On("Output",
94+ append([]any{context1, s}, strings...)...)}
95+}
96+
97+func (_c *mocklanguageRunner_Output_Call) Run(run func(context1 context.Context, s string, strings ...string)) *mocklanguageRunner_Output_Call {
98+ _c.Call.Run(func(args mock.Arguments) {
99+ var arg0 context.Context
100+ if args[0] != nil {
101+ arg0 = args[0].(context.Context)
102+ }
103+ var arg1 string
104+ if args[1] != nil {
105+ arg1 = args[1].(string)
106+ }
107+ var arg2 []string
108+ var variadicArgs []string
109+ if len(args) > 2 {
110+ variadicArgs = args[2].([]string)
111+ }
112+ arg2 = variadicArgs
113+ run(
114+ arg0,
115+ arg1,
116+ arg2...,
117+ )
118+ })
119+ return _c
120+}
121+
122+func (_c *mocklanguageRunner_Output_Call) Return(bytes []byte, err error) *mocklanguageRunner_Output_Call {
123+ _c.Call.Return(bytes, err)
124+ return _c
125+}
126+
127+func (_c *mocklanguageRunner_Output_Call) RunAndReturn(run func(context1 context.Context, s string, strings ...string) ([]byte, error)) *mocklanguageRunner_Output_Call {
128+ _c.Call.Return(run)
129+ return _c
130+}
M internal/adapters/hints/ocr.go +21 -1
@@ -41,7 +41,25 @@ type OCRWord struct {
4141 var languageWarning sync.Once
4242 var failureWarning sync.Once
4343
44+// languageRunner bounds the external language-discovery process independently
45+// of recognition, which has not started yet when discovery runs.
46+type languageRunner interface {
47+ Output(context.Context, string, ...string) ([]byte, error)
48+}
49+
50+type execLanguageRunner struct{}
51+
52+func (execLanguageRunner) Output(ctx context.Context, binary string, args ...string) ([]byte, error) {
53+ return exec.CommandContext(ctx, binary, args...).Output()
54+}
55+
56+const languageTimeout = 5 * time.Second
57+
4458 func ocrLanguage(ctx context.Context, binary string) (string, error) {
59+ return discoverLanguage(ctx, binary, execLanguageRunner{}, languageTimeout)
60+}
61+
62+func discoverLanguage(ctx context.Context, binary string, runner languageRunner, timeout time.Duration) (string, error) {
4563 dir := os.Getenv("TESSDATA_PREFIX")
4664 if dir == "" {
4765 dir = "/usr/share/tessdata"
@@ -49,7 +67,9 @@ func ocrLanguage(ctx context.Context, binary string) (string, error) {
4967 if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil {
5068 return "eng", nil
5169 }
52- out, err := exec.CommandContext(ctx, binary, "--list-langs").Output()
70+ ctx, cancel := context.WithTimeout(ctx, timeout)
71+ defer cancel()
72+ out, err := runner.Output(ctx, binary, "--list-langs")
5373 if err != nil {
5474 return "", err
5575 }
@@ -41,7 +41,25 @@ type OCRWord struct {
41 var languageWarning sync.Once41 var languageWarning sync.Once
42 var failureWarning sync.Once42 var failureWarning sync.Once
43 43
44+// languageRunner bounds the external language-discovery process independently
45+// of recognition, which has not started yet when discovery runs.
46+type languageRunner interface {
47+ Output(context.Context, string, ...string) ([]byte, error)
48+}
49+
50+type execLanguageRunner struct{}
51+
52+func (execLanguageRunner) Output(ctx context.Context, binary string, args ...string) ([]byte, error) {
53+ return exec.CommandContext(ctx, binary, args...).Output()
54+}
55+
56+const languageTimeout = 5 * time.Second
57+
44 func ocrLanguage(ctx context.Context, binary string) (string, error) {58 func ocrLanguage(ctx context.Context, binary string) (string, error) {
59+ return discoverLanguage(ctx, binary, execLanguageRunner{}, languageTimeout)
60+}
61+
62+func discoverLanguage(ctx context.Context, binary string, runner languageRunner, timeout time.Duration) (string, error) {
45 dir := os.Getenv("TESSDATA_PREFIX")63 dir := os.Getenv("TESSDATA_PREFIX")
46 if dir == "" {64 if dir == "" {
47 dir = "/usr/share/tessdata"65 dir = "/usr/share/tessdata"
@@ -49,7 +67,9 @@ func ocrLanguage(ctx context.Context, binary string) (string, error) {
49 if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil {67 if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil {
50 return "eng", nil68 return "eng", nil
51 }69 }
52- out, err := exec.CommandContext(ctx, binary, "--list-langs").Output()70+ ctx, cancel := context.WithTimeout(ctx, timeout)
71+ defer cancel()
72+ out, err := runner.Output(ctx, binary, "--list-langs")
53 if err != nil {73 if err != nil {
54 return "", err74 return "", err
55 }75 }
M internal/adapters/hints/ocr_test.go +34 -0
@@ -2,6 +2,7 @@ package hints
22
33 import (
44 "context"
5+ "errors"
56 "image"
67 "image/draw"
78 "image/png"
@@ -14,6 +15,8 @@ import (
1415 "testing"
1516 "time"
1617
18+ "github.com/stretchr/testify/mock"
19+
1720 "github.com/bnema/neferwl/internal/ports"
1821 )
1922
@@ -236,3 +239,34 @@ func TestRecognizeCapsProcesses(t *testing.T) {
236239 }
237240 }
238241 }
242+
243+func TestLanguageDiscoveryTimeout(t *testing.T) {
244+ t.Setenv("TESSDATA_PREFIX", t.TempDir())
245+ runner := newMocklanguageRunner(t)
246+ parent := context.Background()
247+ runner.EXPECT().Output(mock.Anything, "tesseract", []string{"--list-langs"}).RunAndReturn(
248+ func(ctx context.Context, _ string, _ ...string) ([]byte, error) {
249+ deadline, ok := ctx.Deadline()
250+ if !ok || time.Until(deadline) > languageTimeout {
251+ t.Fatal("language discovery has no bounded deadline")
252+ }
253+ <-ctx.Done()
254+ return nil, ctx.Err()
255+ }).Once()
256+ lang, err := discoverLanguage(parent, "tesseract", runner, 0)
257+ if lang != "" || !errors.Is(err, context.DeadlineExceeded) || parent.Err() != nil {
258+ t.Fatalf("language=%q error=%v parent=%v", lang, err, parent.Err())
259+ }
260+}
261+
262+func TestLanguageDiscoveryFailureKeepsAccessibilityOnly(t *testing.T) {
263+ w := &worker{ocr: ocrState{done: true, err: context.DeadlineExceeded}}
264+ ar := atspiResult{targets: []ports.HintTarget{{Window: 1, Rect: ports.Rect{W: 20, H: 10}}}}
265+ req := ports.HintScanRequest{Width: 100, Height: 100, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}}
266+ if w.needFrame(context.Background(), req, ar) {
267+ t.Fatal("timed out discovery must not require an OCR capture")
268+ }
269+ if err := w.ocr.resolve(context.Background()); !errors.Is(err, context.DeadlineExceeded) {
270+ t.Fatalf("discovery failure was not retained: %v", err)
271+ }
272+}
@@ -2,6 +2,7 @@ package hints
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "image"6 "image"
6 "image/draw"7 "image/draw"
7 "image/png"8 "image/png"
@@ -14,6 +15,8 @@ import (
14 "testing"15 "testing"
15 "time"16 "time"
16 17
18+ "github.com/stretchr/testify/mock"
19+
17 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
18 )21 )
19 22
@@ -236,3 +239,34 @@ func TestRecognizeCapsProcesses(t *testing.T) {
236 }239 }
237 }240 }
238 }241 }
242+
243+func TestLanguageDiscoveryTimeout(t *testing.T) {
244+ t.Setenv("TESSDATA_PREFIX", t.TempDir())
245+ runner := newMocklanguageRunner(t)
246+ parent := context.Background()
247+ runner.EXPECT().Output(mock.Anything, "tesseract", []string{"--list-langs"}).RunAndReturn(
248+ func(ctx context.Context, _ string, _ ...string) ([]byte, error) {
249+ deadline, ok := ctx.Deadline()
250+ if !ok || time.Until(deadline) > languageTimeout {
251+ t.Fatal("language discovery has no bounded deadline")
252+ }
253+ <-ctx.Done()
254+ return nil, ctx.Err()
255+ }).Once()
256+ lang, err := discoverLanguage(parent, "tesseract", runner, 0)
257+ if lang != "" || !errors.Is(err, context.DeadlineExceeded) || parent.Err() != nil {
258+ t.Fatalf("language=%q error=%v parent=%v", lang, err, parent.Err())
259+ }
260+}
261+
262+func TestLanguageDiscoveryFailureKeepsAccessibilityOnly(t *testing.T) {
263+ w := &worker{ocr: ocrState{done: true, err: context.DeadlineExceeded}}
264+ ar := atspiResult{targets: []ports.HintTarget{{Window: 1, Rect: ports.Rect{W: 20, H: 10}}}}
265+ req := ports.HintScanRequest{Width: 100, Height: 100, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}}
266+ if w.needFrame(context.Background(), req, ar) {
267+ t.Fatal("timed out discovery must not require an OCR capture")
268+ }
269+ if err := w.ocr.resolve(context.Background()); !errors.Is(err, context.DeadlineExceeded) {
270+ t.Fatalf("discovery failure was not retained: %v", err)
271+ }
272+}
M internal/adapters/wayland/commands_test.go +2 -2
@@ -258,8 +258,8 @@ func TestCommandRingStats(t *testing.T) {
258258 if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {
259259 t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)
260260 }
261- if st := r.takeStats(); st != (ringStats{}) {
262- t.Fatalf("stats after take = %+v, want zero", st)
261+ if st := r.takeStats(); st != (ringStats{maxLen: 3}) {
262+ t.Fatalf("stats after take = %+v, want current occupancy only", st)
263263 }
264264 release.do()
265265 }
@@ -258,8 +258,8 @@ func TestCommandRingStats(t *testing.T) {
258 if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {258 if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {
259 t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)259 t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)
260 }260 }
261- if st := r.takeStats(); st != (ringStats{}) {261+ if st := r.takeStats(); st != (ringStats{maxLen: 3}) {
262- t.Fatalf("stats after take = %+v, want zero", st)262+ t.Fatalf("stats after take = %+v, want current occupancy only", st)
263 }263 }
264 release.do()264 release.do()
265 }265 }
M internal/adapters/wayland/dnd.go +27 -7
@@ -58,9 +58,11 @@ type dndTarget struct {
5858 surface *wayland.Surface
5959 devices []*wayland.DataDevice
6060 offers []*dndOffer
61- // accepted is the MIME type the target accepted; action the action
62- // chosen for the source.
61+ // accepted is the MIME type the target accepted, owned by acceptor.
62+ // action is the shared result of the latest set_actions request,
63+ // broadcast to every offer so drop and finish use the same result.
6364 accepted string
65+ acceptor *dndOffer
6466 action uint32
6567 state int
6668 // legacy: no device speaks wl_data_offer v3: the target copies and
@@ -118,6 +120,9 @@ func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
118120 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
119121 if len(list) == 0 {
120122 delete(s.dataDevices, c)
123+ if d := s.dnd; d != nil && d.target != nil && d.target.surface.Client() == c {
124+ s.dndLeave(d)
125+ }
121126 // An internal drag has nobody left to tell.
122127 if d := s.dnd; d != nil && d.client == c && d.src == nil {
123128 s.dndEnd(d, false, true)
@@ -391,7 +396,7 @@ func (s *Server) dndDrop(d *dndState) {
391396 }
392397 }
393398 }
394- if t.accepted == "" || t.action == 0 {
399+ if !t.legacy && t.accepted == "" || t.action == 0 {
395400 s.dndEnd(d, true, false)
396401 return
397402 }
@@ -487,6 +492,16 @@ func (s *Server) dndCancelAll() {
487492 func (s *Server) dndOfferGone(o *dndOffer) {
488493 t := o.t
489494 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
495+ if t.state == targetActive && t.acceptor == o {
496+ t.acceptor = nil
497+ s.dndResetSource(t)
498+ for _, offer := range t.offers {
499+ if offer.sent != 0 && offer.res.Version() >= 3 {
500+ offer.sent = 0
501+ offer.res.SendAction(0)
502+ }
503+ }
504+ }
490505 if len(t.offers) != 0 {
491506 return
492507 }
@@ -520,10 +535,14 @@ func (o *dndOffer) update() {
520535 src.res.SendAction(action)
521536 }
522537 }
523- if t.state == targetActive && action != o.sent {
524- o.sent = action
525- if o.res.Version() >= 3 {
526- o.res.SendAction(action)
538+ if t.state == targetActive {
539+ for _, offer := range t.offers {
540+ if action != offer.sent {
541+ offer.sent = action
542+ if offer.res.Version() >= 3 {
543+ offer.res.SendAction(action)
544+ }
545+ }
527546 }
528547 }
529548 }
@@ -534,6 +553,7 @@ func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
534553 return
535554 }
536555 t.accepted = mime
556+ t.acceptor = o
537557 t.d.src.res.SendTarget(mime)
538558 }
539559
@@ -58,9 +58,11 @@ type dndTarget struct {
58 surface *wayland.Surface58 surface *wayland.Surface
59 devices []*wayland.DataDevice59 devices []*wayland.DataDevice
60 offers []*dndOffer60 offers []*dndOffer
61- // accepted is the MIME type the target accepted; action the action61+ // accepted is the MIME type the target accepted, owned by acceptor.
62- // chosen for the source.62+ // action is the shared result of the latest set_actions request,
63+ // broadcast to every offer so drop and finish use the same result.
63 accepted string64 accepted string
65+ acceptor *dndOffer
64 action uint3266 action uint32
65 state int67 state int
66 // legacy: no device speaks wl_data_offer v3: the target copies and68 // legacy: no device speaks wl_data_offer v3: the target copies and
@@ -118,6 +120,9 @@ func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
118 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })120 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
119 if len(list) == 0 {121 if len(list) == 0 {
120 delete(s.dataDevices, c)122 delete(s.dataDevices, c)
123+ if d := s.dnd; d != nil && d.target != nil && d.target.surface.Client() == c {
124+ s.dndLeave(d)
125+ }
121 // An internal drag has nobody left to tell.126 // An internal drag has nobody left to tell.
122 if d := s.dnd; d != nil && d.client == c && d.src == nil {127 if d := s.dnd; d != nil && d.client == c && d.src == nil {
123 s.dndEnd(d, false, true)128 s.dndEnd(d, false, true)
@@ -391,7 +396,7 @@ func (s *Server) dndDrop(d *dndState) {
391 }396 }
392 }397 }
393 }398 }
394- if t.accepted == "" || t.action == 0 {399+ if !t.legacy && t.accepted == "" || t.action == 0 {
395 s.dndEnd(d, true, false)400 s.dndEnd(d, true, false)
396 return401 return
397 }402 }
@@ -487,6 +492,16 @@ func (s *Server) dndCancelAll() {
487 func (s *Server) dndOfferGone(o *dndOffer) {492 func (s *Server) dndOfferGone(o *dndOffer) {
488 t := o.t493 t := o.t
489 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })494 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
495+ if t.state == targetActive && t.acceptor == o {
496+ t.acceptor = nil
497+ s.dndResetSource(t)
498+ for _, offer := range t.offers {
499+ if offer.sent != 0 && offer.res.Version() >= 3 {
500+ offer.sent = 0
501+ offer.res.SendAction(0)
502+ }
503+ }
504+ }
490 if len(t.offers) != 0 {505 if len(t.offers) != 0 {
491 return506 return
492 }507 }
@@ -520,10 +535,14 @@ func (o *dndOffer) update() {
520 src.res.SendAction(action)535 src.res.SendAction(action)
521 }536 }
522 }537 }
523- if t.state == targetActive && action != o.sent {538+ if t.state == targetActive {
524- o.sent = action539+ for _, offer := range t.offers {
525- if o.res.Version() >= 3 {540+ if action != offer.sent {
526- o.res.SendAction(action)541+ offer.sent = action
542+ if offer.res.Version() >= 3 {
543+ offer.res.SendAction(action)
544+ }
545+ }
527 }546 }
528 }547 }
529 }548 }
@@ -534,6 +553,7 @@ func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
534 return553 return
535 }554 }
536 t.accepted = mime555 t.accepted = mime
556+ t.acceptor = o
537 t.d.src.res.SendTarget(mime)557 t.d.src.res.SendTarget(mime)
538 }558 }
539 559
M internal/adapters/wayland/dnd_test.go +137 -31
@@ -198,13 +198,13 @@ func (d *dndClient) expect(t *testing.T, name string) dndEvent {
198198 }
199199
200200 // none checks that no event arrives.
201-func (d *dndClient) none(t *testing.T) {
201+func (d *dndClient) none(t *testing.T, commands chan<- ports.ClientCommand) {
202202 t.Helper()
203- for range 2 {
204- if err := d.c.Roundtrip(); err != nil {
205- t.Fatal(err)
206- }
207- time.Sleep(30 * time.Millisecond)
203+ // Observing a key sent through the command queue proves earlier
204+ // compositor commands ran. The roundtrip then drains wire events.
205+ d.key(t, commands)
206+ if err := d.c.Roundtrip(); err != nil {
207+ t.Fatal(err)
208208 }
209209 select {
210210 case ev := <-d.log:
@@ -366,17 +366,26 @@ func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientE
366366 }
367367
368368 // dndNoEvent checks that no client event of type T is pending.
369-func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {
369+func dndNoEvent[T ports.ClientEvent](t *testing.T, s *Server, events <-chan ports.ClientEvent) {
370370 t.Helper()
371- time.Sleep(50 * time.Millisecond)
371+ // Callers have observed completion of the triggering request/command.
372+ // Put a marker behind its emitted events, including the forwarding queue.
373+ marker := ports.WindowResized{ID: ^ports.WindowID(0)}
374+ if !s.display.Do(func() { s.emit(marker) }) {
375+ t.Fatal("display stopped")
376+ }
377+ deadline := time.After(2 * time.Second)
372378 for {
373379 select {
374380 case ev := <-events:
381+ if v, ok := ev.(ports.WindowResized); ok && v.ID == marker.ID {
382+ return
383+ }
375384 if _, ok := ev.(T); ok {
376385 t.Fatalf("unexpected %T: %+v", ev, ev)
377386 }
378- default:
379- return
387+ case <-deadline:
388+ t.Fatal("no event barrier")
380389 }
381390 }
382391 }
@@ -483,10 +492,10 @@ func TestDataDragBetweenClients(t *testing.T) {
483492 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
484493 p.a.expect(t, "finished")
485494 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
486- p.a.none(t)
487- p.b.none(t)
495+ p.a.none(t, p.commands)
496+ p.b.none(t, p.commands)
488497 // core ended the drag: wayland does not echo it.
489- dndNoEvent[ports.DataDragEnded](t, p.events)
498+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
490499 }
491500
492501 // The action follows wlroots: the preferred one if both allow it, else the
@@ -534,7 +543,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
534543 p.start(t, 0)
535544 p.commands <- ports.DataDragDrop{}
536545 p.a.expect(t, "cancelled")
537- p.b.none(t)
546+ p.b.none(t, p.commands)
538547 })
539548 t.Run("not accepted", func(t *testing.T) {
540549 p := newDndPair(t)
@@ -543,7 +552,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
543552 p.commands <- ports.DataDragDrop{}
544553 p.b.expect(t, "leave")
545554 p.a.expect(t, "cancelled")
546- p.b.none(t)
555+ p.b.none(t, p.commands)
547556 })
548557 t.Run("escape", func(t *testing.T) {
549558 p := newDndPair(t)
@@ -552,7 +561,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
552561 p.commands <- ports.DataDragCancel{}
553562 p.b.expect(t, "leave")
554563 p.a.expect(t, "cancelled")
555- dndNoEvent[ports.DataDragEnded](t, p.events)
564+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
556565 })
557566 }
558567
@@ -587,7 +596,7 @@ func TestDataDragSourceDestroyed(t *testing.T) {
587596 dndNextEvent[ports.DataDragEnded](t, p.events)
588597 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
589598 p.commands <- ports.DataDragDrop{}
590- p.b.none(t)
599+ p.b.none(t, p.commands)
591600 }
592601
593602 // A drag whose origin client dies ends the same way.
@@ -608,7 +617,7 @@ func TestDataDragInvalidStart(t *testing.T) {
608617 serial := p.a.press(t, p.commands)
609618 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
610619 p.a.expect(t, "cancelled")
611- dndNoEvent[ports.DataDragStarted](t, p.events)
620+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
612621 })
613622 t.Run("other client's press", func(t *testing.T) {
614623 p := newDndPair(t)
@@ -616,7 +625,7 @@ func TestDataDragInvalidStart(t *testing.T) {
616625 serial := p.b.press(t, p.commands)
617626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
618627 p.a.expect(t, "cancelled")
619- dndNoEvent[ports.DataDragStarted](t, p.events)
628+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
620629 })
621630 t.Run("key serial", func(t *testing.T) {
622631 p := newDndPair(t)
@@ -625,7 +634,7 @@ func TestDataDragInvalidStart(t *testing.T) {
625634 serial := p.a.key(t, p.commands)
626635 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
627636 p.a.expect(t, "cancelled")
628- dndNoEvent[ports.DataDragStarted](t, p.events)
637+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
629638 })
630639 t.Run("serial after the release", func(t *testing.T) {
631640 p := newDndPair(t)
@@ -633,7 +642,7 @@ func TestDataDragInvalidStart(t *testing.T) {
633642 p.a.release(t, p.commands)
634643 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
635644 p.a.expect(t, "cancelled")
636- dndNoEvent[ports.DataDragStarted](t, p.events)
645+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
637646 })
638647 t.Run("other surface of the client", func(t *testing.T) {
639648 p := newDndPair(t)
@@ -641,7 +650,7 @@ func TestDataDragInvalidStart(t *testing.T) {
641650 other := p.a.surfaceNew(t)
642651 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)
643652 p.a.expect(t, "cancelled")
644- dndNoEvent[ports.DataDragStarted](t, p.events)
653+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
645654 })
646655 t.Run("press of the first button", func(t *testing.T) {
647656 // A second button does not make a new grab: only the first serial.
@@ -675,7 +684,7 @@ func TestDataDragInvalidStart(t *testing.T) {
675684 p.a.key(t, p.commands) // the release is applied once the key is seen
676685 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677686 p.a.expect(t, "cancelled")
678- dndNoEvent[ports.DataDragStarted](t, p.events)
687+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
679688 })
680689 t.Run("used source", func(t *testing.T) {
681690 p := newDndPair(t)
@@ -692,7 +701,7 @@ func TestDataDragInvalidStart(t *testing.T) {
692701 other := p.a.source(t, actCopy, "text/plain")
693702 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
694703 p.a.expect(t, "cancelled")
695- dndNoEvent[ports.DataDragStarted](t, p.events)
704+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
696705 })
697706 t.Run("icon with another role", func(t *testing.T) {
698707 p := newDndPair(t)
@@ -701,7 +710,7 @@ func TestDataDragInvalidStart(t *testing.T) {
701710 // The origin toplevel is a role already.
702711 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
703712 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
704- dndNoEvent[ports.DataDragStarted](t, p.events)
713+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
705714 })
706715 }
707716
@@ -712,7 +721,7 @@ func TestDataDragWithoutSource(t *testing.T) {
712721 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
713722 dndNextEvent[ports.DataDragStarted](t, p.events)
714723 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
715- p.b.none(t)
724+ p.b.none(t, p.commands)
716725 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
717726 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
718727 t.Fatalf("enter %+v", ev)
@@ -837,7 +846,7 @@ func TestDataDragIcon(t *testing.T) {
837846 t.Fatalf("icon %+v", got)
838847 }
839848 // The icon never takes input.
840- dndNoEvent[ports.InputRegionChanged](t, p.events)
849+ dndNoEvent[ports.InputRegionChanged](t, p.s, p.events)
841850
842851 p.commands <- ports.DataDragCancel{}
843852 p.a.expect(t, "cancelled")
@@ -1064,8 +1073,8 @@ func TestDataDragAsk(t *testing.T) {
10641073 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")
10651074 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
10661075 p.a.expect(t, "target")
1067- p.b.none(t) // no offer.action after the drop
1068- p.a.none(t) // the final action waits for the finish
1076+ p.b.none(t, p.commands) // no offer.action after the drop
1077+ p.a.none(t, p.commands) // the final action waits for the finish
10691078 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)
10701079 if ev := p.a.expect(t, "action"); ev.n != actMove {
10711080 t.Fatalf("action %+v", ev)
@@ -1104,8 +1113,7 @@ func TestDataDragLegacyTarget(t *testing.T) {
11041113 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
11051114 p.b.until(t, "enter")
11061115 id := p.b.lastOffer(t)
1107- requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1108- p.a.expect(t, "target")
1116+ // Legacy accept is feedback only: deliberately never send it.
11091117 return p, id
11101118 }
11111119 t.Run("move only", func(t *testing.T) {
@@ -1152,3 +1160,101 @@ func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) {
11521160 t.Fatalf("icon %+v", got)
11531161 }
11541162 }
1163+
1164+func TestDataDragLastTargetDeviceReleased(t *testing.T) {
1165+ p := newDndPair(t)
1166+ p.start(t, 0)
1167+ offer := p.enter(t)
1168+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1169+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
1170+ p.a.expect(t, "target")
1171+ p.a.expect(t, "action")
1172+ p.b.expect(t, "offer_action")
1173+ requestProtocol(t, p.b.c, p.b.device.ID(), wayland.DataDeviceRequestRelease)
1174+ if err := p.b.c.Roundtrip(); err != nil {
1175+ t.Fatal(err)
1176+ }
1177+ p.commands <- ports.DataDragDrop{}
1178+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1179+ t.Fatalf("target %+v", ev)
1180+ }
1181+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1182+ t.Fatalf("action %+v", ev)
1183+ }
1184+ p.a.expect(t, "cancelled")
1185+ // The offer was deliberately kept alive; it must not keep a pending drop.
1186+ if !p.s.display.Do(func() {
1187+ if len(p.s.dndDrops) != 0 {
1188+ t.Error("pending drop without a device")
1189+ }
1190+ }) {
1191+ t.Fatal("display stopped")
1192+ }
1193+}
1194+
1195+func TestDataDragMultipleOffersShareAction(t *testing.T) {
1196+ for _, name := range []string{"finish", "acceptor destroyed"} {
1197+ t.Run(name, func(t *testing.T) {
1198+ p := newDndPair(t)
1199+ seat := bindVersion(t, p.b.c, "wl_seat", 8)
1200+ second := &dndDeviceEvents{c: p.b.c, log: make(chan dndEvent, 64)}
1201+ second.SetID(p.b.c.AllocateID())
1202+ registerWireProxy(p.b.c, second)
1203+ requestProtocol(t, p.b.c, p.b.manager, wayland.DataDeviceManagerRequestGetDataDevice, second.ID(), seat)
1204+ if err := p.b.c.Roundtrip(); err != nil {
1205+ t.Fatal(err)
1206+ }
1207+ p.start(t, 0)
1208+ first := p.enter(t)
1209+ other := &dndClient{c: p.b.c, device: second, log: second.log}
1210+ other.until(t, "enter")
1211+ two := other.lastOffer(t)
1212+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1213+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestSetActions, actCopy|actMove, actCopy)
1214+ p.a.expect(t, "target")
1215+ p.a.expect(t, "action")
1216+ for _, d := range []*dndClient{p.b, other} {
1217+ if ev := d.expect(t, "offer_action"); ev.n != actCopy {
1218+ t.Fatalf("copy: %+v", ev)
1219+ }
1220+ }
1221+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1222+ if ev := p.a.expect(t, "action"); ev.n != actMove {
1223+ t.Fatalf("source action %+v", ev)
1224+ }
1225+ for _, d := range []*dndClient{p.b, other} {
1226+ if ev := d.expect(t, "offer_action"); ev.n != actMove {
1227+ t.Fatalf("move: %+v", ev)
1228+ }
1229+ }
1230+ if name == "finish" {
1231+ p.commands <- ports.DataDragDrop{}
1232+ for _, d := range []*dndClient{p.b, other} {
1233+ d.expect(t, "drop")
1234+ d.expect(t, "leave")
1235+ }
1236+ p.a.expect(t, "drop_performed")
1237+ // Either offer finishes the same negotiated move transfer.
1238+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestFinish)
1239+ p.a.expect(t, "finished")
1240+ return
1241+ }
1242+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestDestroy)
1243+ if err := p.b.c.Roundtrip(); err != nil {
1244+ t.Fatal(err)
1245+ }
1246+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1247+ t.Fatalf("stale acceptance %+v", ev)
1248+ }
1249+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1250+ t.Fatalf("stale action %+v", ev)
1251+ }
1252+ if ev := other.expect(t, "offer_action"); ev.n != 0 {
1253+ t.Fatalf("remaining offer %+v", ev)
1254+ }
1255+ p.commands <- ports.DataDragDrop{}
1256+ p.a.expect(t, "cancelled")
1257+ other.expect(t, "leave")
1258+ })
1259+ }
1260+}
@@ -198,13 +198,13 @@ func (d *dndClient) expect(t *testing.T, name string) dndEvent {
198 }198 }
199 199
200 // none checks that no event arrives.200 // none checks that no event arrives.
201-func (d *dndClient) none(t *testing.T) {201+func (d *dndClient) none(t *testing.T, commands chan<- ports.ClientCommand) {
202 t.Helper()202 t.Helper()
203- for range 2 {203+ // Observing a key sent through the command queue proves earlier
204- if err := d.c.Roundtrip(); err != nil {204+ // compositor commands ran. The roundtrip then drains wire events.
205- t.Fatal(err)205+ d.key(t, commands)
206- }206+ if err := d.c.Roundtrip(); err != nil {
207- time.Sleep(30 * time.Millisecond)207+ t.Fatal(err)
208 }208 }
209 select {209 select {
210 case ev := <-d.log:210 case ev := <-d.log:
@@ -366,17 +366,26 @@ func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientE
366 }366 }
367 367
368 // dndNoEvent checks that no client event of type T is pending.368 // dndNoEvent checks that no client event of type T is pending.
369-func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {369+func dndNoEvent[T ports.ClientEvent](t *testing.T, s *Server, events <-chan ports.ClientEvent) {
370 t.Helper()370 t.Helper()
371- time.Sleep(50 * time.Millisecond)371+ // Callers have observed completion of the triggering request/command.
372+ // Put a marker behind its emitted events, including the forwarding queue.
373+ marker := ports.WindowResized{ID: ^ports.WindowID(0)}
374+ if !s.display.Do(func() { s.emit(marker) }) {
375+ t.Fatal("display stopped")
376+ }
377+ deadline := time.After(2 * time.Second)
372 for {378 for {
373 select {379 select {
374 case ev := <-events:380 case ev := <-events:
381+ if v, ok := ev.(ports.WindowResized); ok && v.ID == marker.ID {
382+ return
383+ }
375 if _, ok := ev.(T); ok {384 if _, ok := ev.(T); ok {
376 t.Fatalf("unexpected %T: %+v", ev, ev)385 t.Fatalf("unexpected %T: %+v", ev, ev)
377 }386 }
378- default:387+ case <-deadline:
379- return388+ t.Fatal("no event barrier")
380 }389 }
381 }390 }
382 }391 }
@@ -483,10 +492,10 @@ func TestDataDragBetweenClients(t *testing.T) {
483 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)492 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
484 p.a.expect(t, "finished")493 p.a.expect(t, "finished")
485 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)494 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
486- p.a.none(t)495+ p.a.none(t, p.commands)
487- p.b.none(t)496+ p.b.none(t, p.commands)
488 // core ended the drag: wayland does not echo it.497 // core ended the drag: wayland does not echo it.
489- dndNoEvent[ports.DataDragEnded](t, p.events)498+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
490 }499 }
491 500
492 // The action follows wlroots: the preferred one if both allow it, else the501 // The action follows wlroots: the preferred one if both allow it, else the
@@ -534,7 +543,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
534 p.start(t, 0)543 p.start(t, 0)
535 p.commands <- ports.DataDragDrop{}544 p.commands <- ports.DataDragDrop{}
536 p.a.expect(t, "cancelled")545 p.a.expect(t, "cancelled")
537- p.b.none(t)546+ p.b.none(t, p.commands)
538 })547 })
539 t.Run("not accepted", func(t *testing.T) {548 t.Run("not accepted", func(t *testing.T) {
540 p := newDndPair(t)549 p := newDndPair(t)
@@ -543,7 +552,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
543 p.commands <- ports.DataDragDrop{}552 p.commands <- ports.DataDragDrop{}
544 p.b.expect(t, "leave")553 p.b.expect(t, "leave")
545 p.a.expect(t, "cancelled")554 p.a.expect(t, "cancelled")
546- p.b.none(t)555+ p.b.none(t, p.commands)
547 })556 })
548 t.Run("escape", func(t *testing.T) {557 t.Run("escape", func(t *testing.T) {
549 p := newDndPair(t)558 p := newDndPair(t)
@@ -552,7 +561,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
552 p.commands <- ports.DataDragCancel{}561 p.commands <- ports.DataDragCancel{}
553 p.b.expect(t, "leave")562 p.b.expect(t, "leave")
554 p.a.expect(t, "cancelled")563 p.a.expect(t, "cancelled")
555- dndNoEvent[ports.DataDragEnded](t, p.events)564+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
556 })565 })
557 }566 }
558 567
@@ -587,7 +596,7 @@ func TestDataDragSourceDestroyed(t *testing.T) {
587 dndNextEvent[ports.DataDragEnded](t, p.events)596 dndNextEvent[ports.DataDragEnded](t, p.events)
588 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}597 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
589 p.commands <- ports.DataDragDrop{}598 p.commands <- ports.DataDragDrop{}
590- p.b.none(t)599+ p.b.none(t, p.commands)
591 }600 }
592 601
593 // A drag whose origin client dies ends the same way.602 // A drag whose origin client dies ends the same way.
@@ -608,7 +617,7 @@ func TestDataDragInvalidStart(t *testing.T) {
608 serial := p.a.press(t, p.commands)617 serial := p.a.press(t, p.commands)
609 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)618 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
610 p.a.expect(t, "cancelled")619 p.a.expect(t, "cancelled")
611- dndNoEvent[ports.DataDragStarted](t, p.events)620+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
612 })621 })
613 t.Run("other client's press", func(t *testing.T) {622 t.Run("other client's press", func(t *testing.T) {
614 p := newDndPair(t)623 p := newDndPair(t)
@@ -616,7 +625,7 @@ func TestDataDragInvalidStart(t *testing.T) {
616 serial := p.b.press(t, p.commands)625 serial := p.b.press(t, p.commands)
617 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
618 p.a.expect(t, "cancelled")627 p.a.expect(t, "cancelled")
619- dndNoEvent[ports.DataDragStarted](t, p.events)628+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
620 })629 })
621 t.Run("key serial", func(t *testing.T) {630 t.Run("key serial", func(t *testing.T) {
622 p := newDndPair(t)631 p := newDndPair(t)
@@ -625,7 +634,7 @@ func TestDataDragInvalidStart(t *testing.T) {
625 serial := p.a.key(t, p.commands)634 serial := p.a.key(t, p.commands)
626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)635 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
627 p.a.expect(t, "cancelled")636 p.a.expect(t, "cancelled")
628- dndNoEvent[ports.DataDragStarted](t, p.events)637+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
629 })638 })
630 t.Run("serial after the release", func(t *testing.T) {639 t.Run("serial after the release", func(t *testing.T) {
631 p := newDndPair(t)640 p := newDndPair(t)
@@ -633,7 +642,7 @@ func TestDataDragInvalidStart(t *testing.T) {
633 p.a.release(t, p.commands)642 p.a.release(t, p.commands)
634 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)643 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
635 p.a.expect(t, "cancelled")644 p.a.expect(t, "cancelled")
636- dndNoEvent[ports.DataDragStarted](t, p.events)645+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
637 })646 })
638 t.Run("other surface of the client", func(t *testing.T) {647 t.Run("other surface of the client", func(t *testing.T) {
639 p := newDndPair(t)648 p := newDndPair(t)
@@ -641,7 +650,7 @@ func TestDataDragInvalidStart(t *testing.T) {
641 other := p.a.surfaceNew(t)650 other := p.a.surfaceNew(t)
642 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)651 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)
643 p.a.expect(t, "cancelled")652 p.a.expect(t, "cancelled")
644- dndNoEvent[ports.DataDragStarted](t, p.events)653+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
645 })654 })
646 t.Run("press of the first button", func(t *testing.T) {655 t.Run("press of the first button", func(t *testing.T) {
647 // A second button does not make a new grab: only the first serial.656 // A second button does not make a new grab: only the first serial.
@@ -675,7 +684,7 @@ func TestDataDragInvalidStart(t *testing.T) {
675 p.a.key(t, p.commands) // the release is applied once the key is seen684 p.a.key(t, p.commands) // the release is applied once the key is seen
676 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)685 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677 p.a.expect(t, "cancelled")686 p.a.expect(t, "cancelled")
678- dndNoEvent[ports.DataDragStarted](t, p.events)687+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
679 })688 })
680 t.Run("used source", func(t *testing.T) {689 t.Run("used source", func(t *testing.T) {
681 p := newDndPair(t)690 p := newDndPair(t)
@@ -692,7 +701,7 @@ func TestDataDragInvalidStart(t *testing.T) {
692 other := p.a.source(t, actCopy, "text/plain")701 other := p.a.source(t, actCopy, "text/plain")
693 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))702 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
694 p.a.expect(t, "cancelled")703 p.a.expect(t, "cancelled")
695- dndNoEvent[ports.DataDragStarted](t, p.events)704+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
696 })705 })
697 t.Run("icon with another role", func(t *testing.T) {706 t.Run("icon with another role", func(t *testing.T) {
698 p := newDndPair(t)707 p := newDndPair(t)
@@ -701,7 +710,7 @@ func TestDataDragInvalidStart(t *testing.T) {
701 // The origin toplevel is a role already.710 // The origin toplevel is a role already.
702 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)711 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
703 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))712 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
704- dndNoEvent[ports.DataDragStarted](t, p.events)713+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
705 })714 })
706 }715 }
707 716
@@ -712,7 +721,7 @@ func TestDataDragWithoutSource(t *testing.T) {
712 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))721 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
713 dndNextEvent[ports.DataDragStarted](t, p.events)722 dndNextEvent[ports.DataDragStarted](t, p.events)
714 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}723 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
715- p.b.none(t)724+ p.b.none(t, p.commands)
716 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}725 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
717 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {726 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
718 t.Fatalf("enter %+v", ev)727 t.Fatalf("enter %+v", ev)
@@ -837,7 +846,7 @@ func TestDataDragIcon(t *testing.T) {
837 t.Fatalf("icon %+v", got)846 t.Fatalf("icon %+v", got)
838 }847 }
839 // The icon never takes input.848 // The icon never takes input.
840- dndNoEvent[ports.InputRegionChanged](t, p.events)849+ dndNoEvent[ports.InputRegionChanged](t, p.s, p.events)
841 850
842 p.commands <- ports.DataDragCancel{}851 p.commands <- ports.DataDragCancel{}
843 p.a.expect(t, "cancelled")852 p.a.expect(t, "cancelled")
@@ -1064,8 +1073,8 @@ func TestDataDragAsk(t *testing.T) {
1064 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")1073 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")
1065 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)1074 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1066 p.a.expect(t, "target")1075 p.a.expect(t, "target")
1067- p.b.none(t) // no offer.action after the drop1076+ p.b.none(t, p.commands) // no offer.action after the drop
1068- p.a.none(t) // the final action waits for the finish1077+ p.a.none(t, p.commands) // the final action waits for the finish
1069 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)1078 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)
1070 if ev := p.a.expect(t, "action"); ev.n != actMove {1079 if ev := p.a.expect(t, "action"); ev.n != actMove {
1071 t.Fatalf("action %+v", ev)1080 t.Fatalf("action %+v", ev)
@@ -1104,8 +1113,7 @@ func TestDataDragLegacyTarget(t *testing.T) {
1104 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}1113 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
1105 p.b.until(t, "enter")1114 p.b.until(t, "enter")
1106 id := p.b.lastOffer(t)1115 id := p.b.lastOffer(t)
1107- requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")1116+ // Legacy accept is feedback only: deliberately never send it.
1108- p.a.expect(t, "target")
1109 return p, id1117 return p, id
1110 }1118 }
1111 t.Run("move only", func(t *testing.T) {1119 t.Run("move only", func(t *testing.T) {
@@ -1152,3 +1160,101 @@ func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) {
1152 t.Fatalf("icon %+v", got)1160 t.Fatalf("icon %+v", got)
1153 }1161 }
1154 }1162 }
1163+
1164+func TestDataDragLastTargetDeviceReleased(t *testing.T) {
1165+ p := newDndPair(t)
1166+ p.start(t, 0)
1167+ offer := p.enter(t)
1168+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1169+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
1170+ p.a.expect(t, "target")
1171+ p.a.expect(t, "action")
1172+ p.b.expect(t, "offer_action")
1173+ requestProtocol(t, p.b.c, p.b.device.ID(), wayland.DataDeviceRequestRelease)
1174+ if err := p.b.c.Roundtrip(); err != nil {
1175+ t.Fatal(err)
1176+ }
1177+ p.commands <- ports.DataDragDrop{}
1178+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1179+ t.Fatalf("target %+v", ev)
1180+ }
1181+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1182+ t.Fatalf("action %+v", ev)
1183+ }
1184+ p.a.expect(t, "cancelled")
1185+ // The offer was deliberately kept alive; it must not keep a pending drop.
1186+ if !p.s.display.Do(func() {
1187+ if len(p.s.dndDrops) != 0 {
1188+ t.Error("pending drop without a device")
1189+ }
1190+ }) {
1191+ t.Fatal("display stopped")
1192+ }
1193+}
1194+
1195+func TestDataDragMultipleOffersShareAction(t *testing.T) {
1196+ for _, name := range []string{"finish", "acceptor destroyed"} {
1197+ t.Run(name, func(t *testing.T) {
1198+ p := newDndPair(t)
1199+ seat := bindVersion(t, p.b.c, "wl_seat", 8)
1200+ second := &dndDeviceEvents{c: p.b.c, log: make(chan dndEvent, 64)}
1201+ second.SetID(p.b.c.AllocateID())
1202+ registerWireProxy(p.b.c, second)
1203+ requestProtocol(t, p.b.c, p.b.manager, wayland.DataDeviceManagerRequestGetDataDevice, second.ID(), seat)
1204+ if err := p.b.c.Roundtrip(); err != nil {
1205+ t.Fatal(err)
1206+ }
1207+ p.start(t, 0)
1208+ first := p.enter(t)
1209+ other := &dndClient{c: p.b.c, device: second, log: second.log}
1210+ other.until(t, "enter")
1211+ two := other.lastOffer(t)
1212+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1213+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestSetActions, actCopy|actMove, actCopy)
1214+ p.a.expect(t, "target")
1215+ p.a.expect(t, "action")
1216+ for _, d := range []*dndClient{p.b, other} {
1217+ if ev := d.expect(t, "offer_action"); ev.n != actCopy {
1218+ t.Fatalf("copy: %+v", ev)
1219+ }
1220+ }
1221+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1222+ if ev := p.a.expect(t, "action"); ev.n != actMove {
1223+ t.Fatalf("source action %+v", ev)
1224+ }
1225+ for _, d := range []*dndClient{p.b, other} {
1226+ if ev := d.expect(t, "offer_action"); ev.n != actMove {
1227+ t.Fatalf("move: %+v", ev)
1228+ }
1229+ }
1230+ if name == "finish" {
1231+ p.commands <- ports.DataDragDrop{}
1232+ for _, d := range []*dndClient{p.b, other} {
1233+ d.expect(t, "drop")
1234+ d.expect(t, "leave")
1235+ }
1236+ p.a.expect(t, "drop_performed")
1237+ // Either offer finishes the same negotiated move transfer.
1238+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestFinish)
1239+ p.a.expect(t, "finished")
1240+ return
1241+ }
1242+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestDestroy)
1243+ if err := p.b.c.Roundtrip(); err != nil {
1244+ t.Fatal(err)
1245+ }
1246+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1247+ t.Fatalf("stale acceptance %+v", ev)
1248+ }
1249+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1250+ t.Fatalf("stale action %+v", ev)
1251+ }
1252+ if ev := other.expect(t, "offer_action"); ev.n != 0 {
1253+ t.Fatalf("remaining offer %+v", ev)
1254+ }
1255+ p.commands <- ports.DataDragDrop{}
1256+ p.a.expect(t, "cancelled")
1257+ other.expect(t, "leave")
1258+ })
1259+ }
1260+}
M internal/adapters/wayland/imagecapture_test.go +1 -30
@@ -242,39 +242,10 @@ func noSession(t *testing.T, cc *cclient, ev chan []uint32) {
242242 // client received what followed.
243243 func (h *captureHarness) settle(t *testing.T, cc *cclient) {
244244 t.Helper()
245- // The command loop takes commands off the channel before it applies
246- // them on the display: an empty channel does not mean applied. Commands
247- // apply in order, so a marker (SlotsPending, a plain flag) seen on the
248- // display means every command sent before it was applied.
249- var was bool
250- if !h.s.display.Do(func() { was = h.s.slotsPending }) {
251- t.Fatal("display stopped")
252- }
253- h.applied(t, !was)
254- h.applied(t, was)
245+ syncCommands(t, h.s, h.commands, cc.c)
255246 cc.roundtrip(t)
256247 }
257248
258-// applied sends SlotsPending{pending} and waits until the display applied it.
259-func (h *captureHarness) applied(t *testing.T, pending bool) {
260- t.Helper()
261- h.commands <- ports.SlotsPending{Pending: pending}
262- deadline := time.Now().Add(2 * time.Second)
263- for {
264- var now bool
265- if !h.s.display.Do(func() { now = h.s.slotsPending }) {
266- t.Fatal("display stopped")
267- }
268- if now == pending {
269- return
270- }
271- if time.Now().After(deadline) {
272- t.Fatal("commands not applied")
273- }
274- time.Sleep(time.Millisecond)
275- }
276-}
277-
278249 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
279250
280251 // captureShm makes a w x h XRGB8888 wl_buffer.
@@ -242,39 +242,10 @@ func noSession(t *testing.T, cc *cclient, ev chan []uint32) {
242 // client received what followed.242 // client received what followed.
243 func (h *captureHarness) settle(t *testing.T, cc *cclient) {243 func (h *captureHarness) settle(t *testing.T, cc *cclient) {
244 t.Helper()244 t.Helper()
245- // The command loop takes commands off the channel before it applies245+ syncCommands(t, h.s, h.commands, cc.c)
246- // them on the display: an empty channel does not mean applied. Commands
247- // apply in order, so a marker (SlotsPending, a plain flag) seen on the
248- // display means every command sent before it was applied.
249- var was bool
250- if !h.s.display.Do(func() { was = h.s.slotsPending }) {
251- t.Fatal("display stopped")
252- }
253- h.applied(t, !was)
254- h.applied(t, was)
255 cc.roundtrip(t)246 cc.roundtrip(t)
256 }247 }
257 248
258-// applied sends SlotsPending{pending} and waits until the display applied it.
259-func (h *captureHarness) applied(t *testing.T, pending bool) {
260- t.Helper()
261- h.commands <- ports.SlotsPending{Pending: pending}
262- deadline := time.Now().Add(2 * time.Second)
263- for {
264- var now bool
265- if !h.s.display.Do(func() { now = h.s.slotsPending }) {
266- t.Fatal("display stopped")
267- }
268- if now == pending {
269- return
270- }
271- if time.Now().After(deadline) {
272- t.Fatal("commands not applied")
273- }
274- time.Sleep(time.Millisecond)
275- }
276-}
277-
278 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }249 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
279 250
280 // captureShm makes a w x h XRGB8888 wl_buffer.251 // captureShm makes a w x h XRGB8888 wl_buffer.
M internal/adapters/wayland/keyboard_test.go +59 -23
@@ -129,12 +129,16 @@ type eventsProxy struct {
129129 wlturbo.BaseProxy
130130 opcodes []uint16
131131 keymaps []string
132- locked []uint32 // locked modifiers of each modifiers event
133- depress []uint32 // depressed modifiers of each modifiers event
132+ locked []uint32 // locked modifiers of each modifiers event
133+ depress []uint32 // depressed modifiers of each modifiers event
134+ repeat chan struct{} // optional protocol barrier notification
134135 }
135136
136137 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
137138 p.opcodes = append(p.opcodes, e.Opcode)
139+ if e.Opcode == uint16(wayland.KeyboardEventRepeatInfo) && p.repeat != nil {
140+ p.repeat <- struct{}{}
141+ }
138142 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
139143 e.Uint32() // serial
140144 p.depress = append(p.depress, e.Uint32())
@@ -267,30 +271,11 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
267271 set := make(chan struct{})
268272 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269273 <-set
270- // A marker command applied on the display means every command sent before
271- // it was applied (they apply in order); the roundtrip then delivers the
272- // events they sent.
273- var pending bool
274274 wait := func() {
275275 t.Helper()
276- pending = !pending
277- commands <- ports.SlotsPending{Pending: pending}
278- deadline := time.Now().Add(2 * time.Second)
279- for {
280- var now bool
281- if !s.display.Do(func() { now = s.slotsPending }) {
282- t.Fatal("display stopped")
283- }
284- if now == pending {
285- break
286- }
287- if time.Now().After(deadline) {
288- t.Fatal("commands not applied")
289- }
290- time.Sleep(time.Millisecond)
291- }
292- roundtrip(t, target)
276+ syncCommands(t, s, commands, target)
293277 }
278+
294279 proxy.depress, proxy.locked = nil, nil
295280 commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296281 wait()
@@ -341,3 +326,54 @@ func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
341326 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342327 }
343328 }
329+
330+// syncCommands observes an existing command through the real protocol. A
331+// repeat_info emitted by SetKeymap means all preceding commands were applied;
332+// merely emptying the channel or posting a display callback cannot prove that.
333+func syncCommands(t *testing.T, s *Server, commands chan<- ports.ClientCommand, c *wlturbo.Display) {
334+ t.Helper()
335+ var needsKeymap bool
336+ if !s.display.Do(func() { needsKeymap = s.seat.keymapFD < 0 }) {
337+ t.Fatal("display stopped")
338+ }
339+ if needsKeymap {
340+ keymap := keymapText(t)
341+ if !s.display.Do(func() { s.setKeymap(ports.SetKeymap{Keymap: keymap}) }) {
342+ t.Fatal("display stopped")
343+ }
344+ }
345+ seat := bindVersion(t, c, "wl_seat", 4)
346+ registerProtocol(t, c, seat)
347+ id := c.AllocateID()
348+ p := &eventsProxy{repeat: make(chan struct{}, 2)}
349+ p.SetID(id)
350+ registerWireProxy(c, p)
351+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
352+ roundtrip(t, c)
353+ select {
354+ case <-p.repeat:
355+ default:
356+ t.Fatal("missing initial repeat_info")
357+ }
358+ var rate, delay int
359+ if !s.display.Do(func() { rate, delay = s.seat.repeatRate, s.seat.repeatDelay }) {
360+ t.Fatal("display stopped")
361+ }
362+ commands <- ports.SetKeymap{RepeatRate: rate, RepeatDelay: delay}
363+ // Dispatch blocks on the protocol socket; close it on timeout to bound
364+ // failure without polling the display or sleeping for command delivery.
365+ timeout := time.AfterFunc(2*time.Second, func() { _ = c.Close() })
366+ defer timeout.Stop()
367+ for {
368+ select {
369+ case <-p.repeat:
370+ requestProtocol(t, c, id, wayland.KeyboardRequestRelease)
371+ roundtrip(t, c)
372+ return
373+ default:
374+ }
375+ if err := c.Dispatch(); err != nil {
376+ t.Fatal(err)
377+ }
378+ }
379+}
@@ -129,12 +129,16 @@ type eventsProxy struct {
129 wlturbo.BaseProxy129 wlturbo.BaseProxy
130 opcodes []uint16130 opcodes []uint16
131 keymaps []string131 keymaps []string
132- locked []uint32 // locked modifiers of each modifiers event132+ locked []uint32 // locked modifiers of each modifiers event
133- depress []uint32 // depressed modifiers of each modifiers event133+ depress []uint32 // depressed modifiers of each modifiers event
134+ repeat chan struct{} // optional protocol barrier notification
134 }135 }
135 136
136 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {137 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
137 p.opcodes = append(p.opcodes, e.Opcode)138 p.opcodes = append(p.opcodes, e.Opcode)
139+ if e.Opcode == uint16(wayland.KeyboardEventRepeatInfo) && p.repeat != nil {
140+ p.repeat <- struct{}{}
141+ }
138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {142 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
139 e.Uint32() // serial143 e.Uint32() // serial
140 p.depress = append(p.depress, e.Uint32())144 p.depress = append(p.depress, e.Uint32())
@@ -267,30 +271,11 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
267 set := make(chan struct{})271 set := make(chan struct{})
268 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })272 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269 <-set273 <-set
270- // A marker command applied on the display means every command sent before
271- // it was applied (they apply in order); the roundtrip then delivers the
272- // events they sent.
273- var pending bool
274 wait := func() {274 wait := func() {
275 t.Helper()275 t.Helper()
276- pending = !pending276+ syncCommands(t, s, commands, target)
277- commands <- ports.SlotsPending{Pending: pending}
278- deadline := time.Now().Add(2 * time.Second)
279- for {
280- var now bool
281- if !s.display.Do(func() { now = s.slotsPending }) {
282- t.Fatal("display stopped")
283- }
284- if now == pending {
285- break
286- }
287- if time.Now().After(deadline) {
288- t.Fatal("commands not applied")
289- }
290- time.Sleep(time.Millisecond)
291- }
292- roundtrip(t, target)
293 }277 }
278+
294 proxy.depress, proxy.locked = nil, nil279 proxy.depress, proxy.locked = nil, nil
295 commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}280 commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296 wait()281 wait()
@@ -341,3 +326,54 @@ func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
341 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)326 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342 }327 }
343 }328 }
329+
330+// syncCommands observes an existing command through the real protocol. A
331+// repeat_info emitted by SetKeymap means all preceding commands were applied;
332+// merely emptying the channel or posting a display callback cannot prove that.
333+func syncCommands(t *testing.T, s *Server, commands chan<- ports.ClientCommand, c *wlturbo.Display) {
334+ t.Helper()
335+ var needsKeymap bool
336+ if !s.display.Do(func() { needsKeymap = s.seat.keymapFD < 0 }) {
337+ t.Fatal("display stopped")
338+ }
339+ if needsKeymap {
340+ keymap := keymapText(t)
341+ if !s.display.Do(func() { s.setKeymap(ports.SetKeymap{Keymap: keymap}) }) {
342+ t.Fatal("display stopped")
343+ }
344+ }
345+ seat := bindVersion(t, c, "wl_seat", 4)
346+ registerProtocol(t, c, seat)
347+ id := c.AllocateID()
348+ p := &eventsProxy{repeat: make(chan struct{}, 2)}
349+ p.SetID(id)
350+ registerWireProxy(c, p)
351+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
352+ roundtrip(t, c)
353+ select {
354+ case <-p.repeat:
355+ default:
356+ t.Fatal("missing initial repeat_info")
357+ }
358+ var rate, delay int
359+ if !s.display.Do(func() { rate, delay = s.seat.repeatRate, s.seat.repeatDelay }) {
360+ t.Fatal("display stopped")
361+ }
362+ commands <- ports.SetKeymap{RepeatRate: rate, RepeatDelay: delay}
363+ // Dispatch blocks on the protocol socket; close it on timeout to bound
364+ // failure without polling the display or sleeping for command delivery.
365+ timeout := time.AfterFunc(2*time.Second, func() { _ = c.Close() })
366+ defer timeout.Stop()
367+ for {
368+ select {
369+ case <-p.repeat:
370+ requestProtocol(t, c, id, wayland.KeyboardRequestRelease)
371+ roundtrip(t, c)
372+ return
373+ default:
374+ }
375+ if err := c.Dispatch(); err != nil {
376+ t.Fatal(err)
377+ }
378+ }
379+}
M internal/adapters/wayland/postring.go +1 -0
@@ -48,6 +48,7 @@ type ringStats struct {
4848 func (r *postRing[T]) takeStats() ringStats {
4949 r.mu.Lock()
5050 st := r.stats
51+ st.maxLen = max(st.maxLen, len(r.ring))
5152 r.stats = ringStats{}
5253 r.mu.Unlock()
5354 return st
@@ -48,6 +48,7 @@ type ringStats struct {
48 func (r *postRing[T]) takeStats() ringStats {48 func (r *postRing[T]) takeStats() ringStats {
49 r.mu.Lock()49 r.mu.Lock()
50 st := r.stats50 st := r.stats
51+ st.maxLen = max(st.maxLen, len(r.ring))
51 r.stats = ringStats{}52 r.stats = ringStats{}
52 r.mu.Unlock()53 r.mu.Unlock()
53 return st54 return st
M internal/adapters/wayland/postring_test.go +34 -0
@@ -207,3 +207,37 @@ func waitPushWaiting(t *testing.T) {
207207 runtime.Gosched()
208208 }
209209 }
210+
211+func TestPostRingStatsSampleStalledOccupancy(t *testing.T) {
212+ s, _, _, _, _, _ := presentServer(t)
213+ release := make(chan struct{})
214+ stalled := make(chan struct{})
215+ s.display.Post(func() { close(stalled); <-release })
216+ <-stalled
217+ // Release even if an assertion fails, so server cleanup cannot deadlock.
218+ released := false
219+ defer func() {
220+ if !released {
221+ close(release)
222+ }
223+ }()
224+ r := newPostRing(s.display, 2, func(int) {})
225+ for i := range 2 {
226+ if !r.Push(i) {
227+ t.Fatal("push refused")
228+ }
229+ }
230+ for range 2 {
231+ if got := r.takeStats().maxLen; got != 2 {
232+ t.Fatalf("occupancy %d, want 2", got)
233+ }
234+ }
235+ close(release)
236+ released = true
237+ if !s.display.Do(func() {}) {
238+ t.Fatal("display stopped")
239+ }
240+ if got := r.takeStats().maxLen; got != 0 {
241+ t.Fatalf("recovered occupancy %d", got)
242+ }
243+}
@@ -207,3 +207,37 @@ func waitPushWaiting(t *testing.T) {
207 runtime.Gosched()207 runtime.Gosched()
208 }208 }
209 }209 }
210+
211+func TestPostRingStatsSampleStalledOccupancy(t *testing.T) {
212+ s, _, _, _, _, _ := presentServer(t)
213+ release := make(chan struct{})
214+ stalled := make(chan struct{})
215+ s.display.Post(func() { close(stalled); <-release })
216+ <-stalled
217+ // Release even if an assertion fails, so server cleanup cannot deadlock.
218+ released := false
219+ defer func() {
220+ if !released {
221+ close(release)
222+ }
223+ }()
224+ r := newPostRing(s.display, 2, func(int) {})
225+ for i := range 2 {
226+ if !r.Push(i) {
227+ t.Fatal("push refused")
228+ }
229+ }
230+ for range 2 {
231+ if got := r.takeStats().maxLen; got != 2 {
232+ t.Fatalf("occupancy %d, want 2", got)
233+ }
234+ }
235+ close(release)
236+ released = true
237+ if !s.display.Do(func() {}) {
238+ t.Fatal("display stopped")
239+ }
240+ if got := r.takeStats().maxLen; got != 0 {
241+ t.Fatalf("recovered occupancy %d", got)
242+ }
243+}
M internal/adapters/wayland/server.go +0 -3
@@ -162,7 +162,6 @@ type Server struct {
162162 securityLive atomic.Int32
163163 display *server.Display
164164 env procEnv
165- slotsPending bool // core waits for a slot window
166165 name string
167166 cleanup func()
168167 log zerowrap.Logger
@@ -809,8 +808,6 @@ func (s *Server) apply(cmd ports.ClientCommand) {
809808 s.captureState(c)
810809 case ports.UserActivity:
811810 s.userActivity()
812- case ports.SlotsPending:
813- s.slotsPending = c.Pending
814811 case ports.ShortcutsInhibitState:
815812 s.setShortcutsInhibit(c)
816813 case ports.CloseWindow:
@@ -162,7 +162,6 @@ type Server struct {
162 securityLive atomic.Int32162 securityLive atomic.Int32
163 display *server.Display163 display *server.Display
164 env procEnv164 env procEnv
165- slotsPending bool // core waits for a slot window
166 name string165 name string
167 cleanup func()166 cleanup func()
168 log zerowrap.Logger167 log zerowrap.Logger
@@ -809,8 +808,6 @@ func (s *Server) apply(cmd ports.ClientCommand) {
809 s.captureState(c)808 s.captureState(c)
810 case ports.UserActivity:809 case ports.UserActivity:
811 s.userActivity()810 s.userActivity()
812- case ports.SlotsPending:
813- s.slotsPending = c.Pending
814 case ports.ShortcutsInhibitState:811 case ports.ShortcutsInhibitState:
815 s.setShortcutsInhibit(c)812 s.setShortcutsInhibit(c)
816 case ports.CloseWindow:813 case ports.CloseWindow:
M internal/adapters/wayland/slot.go +4 -5
@@ -37,12 +37,11 @@ func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
3737 }
3838
3939 // slotToken returns the SlotEnv value of the client process, if any. It
40-// reads /proc only while core waits for a slot window. There is no per-client
41-// cache: libwayland may reuse a client address after a disconnect.
40+// reads /proc once at toplevel mapping, independently of command application:
41+// a spawned client can map before the display drains core's commands. Core
42+// accepts only tokens for pending placements. There is no per-client cache:
43+// libwayland may reuse a client address after a disconnect.
4244 func (s *Server) slotToken(c server.Client) string {
43- if !s.slotsPending {
44- return ""
45- }
4645 v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
4746 return v
4847 }
@@ -37,12 +37,11 @@ func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
37 }37 }
38 38
39 // slotToken returns the SlotEnv value of the client process, if any. It39 // slotToken returns the SlotEnv value of the client process, if any. It
40-// reads /proc only while core waits for a slot window. There is no per-client40+// reads /proc once at toplevel mapping, independently of command application:
41-// cache: libwayland may reuse a client address after a disconnect.41+// a spawned client can map before the display drains core's commands. Core
42+// accepts only tokens for pending placements. There is no per-client cache:
43+// libwayland may reuse a client address after a disconnect.
42 func (s *Server) slotToken(c server.Client) string {44 func (s *Server) slotToken(c server.Client) string {
43- if !s.slotsPending {
44- return ""
45- }
46 v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)45 v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
47 return v46 return v
48 }47 }
M internal/adapters/wayland/slot_test.go +3 -10
@@ -14,18 +14,11 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
1414 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
1515 s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
1616 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17- // No slot pending: /proc is not read (the mock would fail on a call).
18- if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {
17+ // No command needs to reach the display before this fast client maps.
18+ if w := mapWindow(); w.Slot != "7" || w.PID != os.Getpid() {
1919 t.Fatalf("slot %q pid %d", w.Slot, w.PID)
2020 }
21- // This tests environment lookup, not command transport. Apply the gate
22- // synchronously before mapping over the independent protocol connection.
23- if !s.display.Do(func() { s.apply(ports.SlotsPending{Pending: true}) }) {
24- t.Fatal("display stopped before applying SlotsPending")
25- }
26- if w := mapWindow(); w.Slot != "7" {
27- t.Fatalf("slot %q", w.Slot)
28- }
21+
2922 }
3023
3124 func TestLinuxProcEnv(t *testing.T) {
@@ -14,18 +14,11 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
14 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()14 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15 s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })15 s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17- // No slot pending: /proc is not read (the mock would fail on a call).17+ // No command needs to reach the display before this fast client maps.
18- if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {18+ if w := mapWindow(); w.Slot != "7" || w.PID != os.Getpid() {
19 t.Fatalf("slot %q pid %d", w.Slot, w.PID)19 t.Fatalf("slot %q pid %d", w.Slot, w.PID)
20 }20 }
21- // This tests environment lookup, not command transport. Apply the gate21+
22- // synchronously before mapping over the independent protocol connection.
23- if !s.display.Do(func() { s.apply(ports.SlotsPending{Pending: true}) }) {
24- t.Fatal("display stopped before applying SlotsPending")
25- }
26- if w := mapWindow(); w.Slot != "7" {
27- t.Fatalf("slot %q", w.Slot)
28- }
29 }22 }
30 23
31 func TestLinuxProcEnv(t *testing.T) {24 func TestLinuxProcEnv(t *testing.T) {
M internal/adapters/wayland/xdg.go +6 -1
@@ -239,7 +239,12 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
239239 } else if buffer && !w.mapped && x.acked {
240240 w.mapped = true
241241 w.gen++
242- slot := x.server.slotToken(r.Client())
242+ // Placement claims are for a new toplevel, not later remaps.
243+ // Ordinary commits, popups and remaps need no /proc lookup.
244+ var slot string
245+ if w.gen == 1 {
246+ slot = x.server.slotToken(r.Client())
247+ }
243248 // Its size comes with the commit, in WindowResized (afterCommit).
244249 w.floating = w.floats()
245250 var parent ports.WindowID
@@ -239,7 +239,12 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
239 } else if buffer && !w.mapped && x.acked {239 } else if buffer && !w.mapped && x.acked {
240 w.mapped = true240 w.mapped = true
241 w.gen++241 w.gen++
242- slot := x.server.slotToken(r.Client())242+ // Placement claims are for a new toplevel, not later remaps.
243+ // Ordinary commits, popups and remaps need no /proc lookup.
244+ var slot string
245+ if w.gen == 1 {
246+ slot = x.server.slotToken(r.Client())
247+ }
243 // Its size comes with the commit, in WindowResized (afterCommit).248 // Its size comes with the commit, in WindowResized (afterCommit).
244 w.floating = w.floats()249 w.floating = w.floats()
245 var parent ports.WindowID250 var parent ports.WindowID
M internal/core/core.go +13 -53
@@ -204,7 +204,6 @@ type sentCache struct {
204204 outputs ports.SetOutputs
205205 state ports.State
206206 workspaces ports.Workspaces
207- pending bool // spawn placement pending
208207 }
209208
210209 func keyName(s string) string {
@@ -364,23 +363,6 @@ func (c *Core) apply(cfg ports.Config) error {
364363 return nil
365364 }
366365
367-// publishPending tells wayland at once when slots start waiting, before
368-// their windows can map.
369-func (c *Core) publishPending(ctx context.Context) error {
370- if p := c.placement.anyPending(); p != c.sent.pending {
371- // Pending must be in wayland's queue before the spawn that follows.
372- send := c.command
373- if p {
374- send = c.commandFlushed
375- }
376- if err := send(ctx, ports.SlotsPending{Pending: p}); err != nil {
377- return err
378- }
379- c.sent.pending = p
380- }
381- return nil
382-}
383-
384366 // spawnSlots starts the slots queued by config. With shown set (the user
385367 // just switched to another workspace), it also refills the empty slots of
386368 // the workspace now on screen.
@@ -406,13 +388,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
406388 }
407389 started[key] = true
408390 req := c.spawnSlot(key)
409- // Wayland must read slot tokens before this window can map.
410- pendingErr := c.publishPending(ctx)
411- if pendingErr != nil || c.protectionRequested() {
391+ if err := ctx.Err(); err != nil || c.protectionRequested() {
412392 c.placement.dropSlot(key)
413393 st.stale = false
414394 c.toSpawn = append(c.toSpawn, keys[i:]...)
415- return pendingErr
395+ return err
416396 }
417397 if !c.trySpawn(ctx, req) {
418398 c.placement.dropSlot(key)
@@ -473,26 +453,6 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
473453 return err
474454 }
475455
476-// commandFlushed is command for a command that another channel depends on:
477-// it returns once v is in the command channel. SlotsPending must reach
478-// wayland before the spawn whose window it lets wayland claim; the spawn
479-// goes on its own channel, so queue order alone does not keep them in order.
480-func (c *Core) commandFlushed(ctx context.Context, v ports.ClientCommand) error {
481- seq, err := c.queue(ctx, v)
482- if err != nil || seq == 0 {
483- return err
484- }
485- if err := c.out.flush(ctx, c.ch.Commands, seq); err != nil {
486- return err
487- }
488- // As a blocked send did: the rest of the event must not go on under a
489- // new epoch.
490- if c.inputEpochChanged() {
491- return errSecurityChanged
492- }
493- return nil
494-}
495-
496456 // queue pushes v to the outbox and returns its sequence number (0: not
497457 // queued).
498458 func (c *Core) queue(ctx context.Context, v ports.ClientCommand) (uint64, error) {
@@ -689,9 +649,6 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
689649 if err := c.spawnEmpty(ctx); err != nil {
690650 return err
691651 }
692- if err := c.publishPending(ctx); err != nil {
693- return err
694- }
695652 focus := c.keyboardFocus()
696653 c.advanceFlash(only)
697654 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -1120,7 +1077,7 @@ func (c *Core) Run(ctx context.Context) error {
11201077 c.startup[i] = slices.Clone(argv)
11211078 }
11221079 c.spawnStartup(ctx)
1123- if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
1080+ if c.spawnSlots(ctx, false) != nil {
11241081 return nil
11251082 }
11261083 c.publishState()
@@ -1153,8 +1110,13 @@ func (c *Core) Run(ctx context.Context) error {
11531110 if stop {
11541111 return nil
11551112 }
1156- if publish && c.publish(ctx) != nil {
1157- return nil
1113+ if publish {
1114+ if c.publish(ctx) != nil {
1115+ return nil
1116+ }
1117+ if c.dataDrag != nil && dataDragHitChanged(ev) && c.dataDragRetarget(ctx, c.motionTime) != nil {
1118+ return nil
1119+ }
11581120 }
11591121 case f, ok := <-c.ch.Frames:
11601122 if c.securityCheckpoint(ctx) != nil {
@@ -1350,13 +1312,11 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
13501312 }
13511313
13521314 // dispatch sends reqs to the launcher and reports how many it issued.
1353-// Wayland reads claim tokens only while it knows spawns are pending, so the
1354-// flag is published before the first send. From the first request that is
1355-// not issued (publication failed, or the gate suppressed the send), the
1356-// rest are not sent and their claims are cancelled.
1315+// From the first request that is not issued (context ended, or the gate
1316+// suppressed the send), the rest are not sent and their claims are cancelled.
13571317 func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) {
1358- err = c.publishPending(ctx)
13591318 for i, req := range reqs {
1319+ err = ctx.Err()
13601320 if err != nil || !c.trySpawn(ctx, req) {
13611321 for _, suppressed := range reqs[i:] {
13621322 c.cancelClaim(suppressed)
@@ -204,7 +204,6 @@ type sentCache struct {
204 outputs ports.SetOutputs204 outputs ports.SetOutputs
205 state ports.State205 state ports.State
206 workspaces ports.Workspaces206 workspaces ports.Workspaces
207- pending bool // spawn placement pending
208 }207 }
209 208
210 func keyName(s string) string {209 func keyName(s string) string {
@@ -364,23 +363,6 @@ func (c *Core) apply(cfg ports.Config) error {
364 return nil363 return nil
365 }364 }
366 365
367-// publishPending tells wayland at once when slots start waiting, before
368-// their windows can map.
369-func (c *Core) publishPending(ctx context.Context) error {
370- if p := c.placement.anyPending(); p != c.sent.pending {
371- // Pending must be in wayland's queue before the spawn that follows.
372- send := c.command
373- if p {
374- send = c.commandFlushed
375- }
376- if err := send(ctx, ports.SlotsPending{Pending: p}); err != nil {
377- return err
378- }
379- c.sent.pending = p
380- }
381- return nil
382-}
383-
384 // spawnSlots starts the slots queued by config. With shown set (the user366 // spawnSlots starts the slots queued by config. With shown set (the user
385 // just switched to another workspace), it also refills the empty slots of367 // just switched to another workspace), it also refills the empty slots of
386 // the workspace now on screen.368 // the workspace now on screen.
@@ -406,13 +388,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
406 }388 }
407 started[key] = true389 started[key] = true
408 req := c.spawnSlot(key)390 req := c.spawnSlot(key)
409- // Wayland must read slot tokens before this window can map.391+ if err := ctx.Err(); err != nil || c.protectionRequested() {
410- pendingErr := c.publishPending(ctx)
411- if pendingErr != nil || c.protectionRequested() {
412 c.placement.dropSlot(key)392 c.placement.dropSlot(key)
413 st.stale = false393 st.stale = false
414 c.toSpawn = append(c.toSpawn, keys[i:]...)394 c.toSpawn = append(c.toSpawn, keys[i:]...)
415- return pendingErr395+ return err
416 }396 }
417 if !c.trySpawn(ctx, req) {397 if !c.trySpawn(ctx, req) {
418 c.placement.dropSlot(key)398 c.placement.dropSlot(key)
@@ -473,26 +453,6 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
473 return err453 return err
474 }454 }
475 455
476-// commandFlushed is command for a command that another channel depends on:
477-// it returns once v is in the command channel. SlotsPending must reach
478-// wayland before the spawn whose window it lets wayland claim; the spawn
479-// goes on its own channel, so queue order alone does not keep them in order.
480-func (c *Core) commandFlushed(ctx context.Context, v ports.ClientCommand) error {
481- seq, err := c.queue(ctx, v)
482- if err != nil || seq == 0 {
483- return err
484- }
485- if err := c.out.flush(ctx, c.ch.Commands, seq); err != nil {
486- return err
487- }
488- // As a blocked send did: the rest of the event must not go on under a
489- // new epoch.
490- if c.inputEpochChanged() {
491- return errSecurityChanged
492- }
493- return nil
494-}
495-
496 // queue pushes v to the outbox and returns its sequence number (0: not456 // queue pushes v to the outbox and returns its sequence number (0: not
497 // queued).457 // queued).
498 func (c *Core) queue(ctx context.Context, v ports.ClientCommand) (uint64, error) {458 func (c *Core) queue(ctx context.Context, v ports.ClientCommand) (uint64, error) {
@@ -689,9 +649,6 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
689 if err := c.spawnEmpty(ctx); err != nil {649 if err := c.spawnEmpty(ctx); err != nil {
690 return err650 return err
691 }651 }
692- if err := c.publishPending(ctx); err != nil {
693- return err
694- }
695 focus := c.keyboardFocus()652 focus := c.keyboardFocus()
696 c.advanceFlash(only)653 c.advanceFlash(only)
697 scenes := make([]ports.Scene, 0, len(c.screens))654 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -1120,7 +1077,7 @@ func (c *Core) Run(ctx context.Context) error {
1120 c.startup[i] = slices.Clone(argv)1077 c.startup[i] = slices.Clone(argv)
1121 }1078 }
1122 c.spawnStartup(ctx)1079 c.spawnStartup(ctx)
1123- if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {1080+ if c.spawnSlots(ctx, false) != nil {
1124 return nil1081 return nil
1125 }1082 }
1126 c.publishState()1083 c.publishState()
@@ -1153,8 +1110,13 @@ func (c *Core) Run(ctx context.Context) error {
1153 if stop {1110 if stop {
1154 return nil1111 return nil
1155 }1112 }
1156- if publish && c.publish(ctx) != nil {1113+ if publish {
1157- return nil1114+ if c.publish(ctx) != nil {
1115+ return nil
1116+ }
1117+ if c.dataDrag != nil && dataDragHitChanged(ev) && c.dataDragRetarget(ctx, c.motionTime) != nil {
1118+ return nil
1119+ }
1158 }1120 }
1159 case f, ok := <-c.ch.Frames:1121 case f, ok := <-c.ch.Frames:
1160 if c.securityCheckpoint(ctx) != nil {1122 if c.securityCheckpoint(ctx) != nil {
@@ -1350,13 +1312,11 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
1350 }1312 }
1351 1313
1352 // dispatch sends reqs to the launcher and reports how many it issued.1314 // dispatch sends reqs to the launcher and reports how many it issued.
1353-// Wayland reads claim tokens only while it knows spawns are pending, so the1315+// From the first request that is not issued (context ended, or the gate
1354-// flag is published before the first send. From the first request that is1316+// suppressed the send), the rest are not sent and their claims are cancelled.
1355-// not issued (publication failed, or the gate suppressed the send), the
1356-// rest are not sent and their claims are cancelled.
1357 func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) {1317 func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) {
1358- err = c.publishPending(ctx)
1359 for i, req := range reqs {1318 for i, req := range reqs {
1319+ err = ctx.Err()
1360 if err != nil || !c.trySpawn(ctx, req) {1320 if err != nil || !c.trySpawn(ctx, req) {
1361 for _, suppressed := range reqs[i:] {1321 for _, suppressed := range reqs[i:] {
1362 c.cancelClaim(suppressed)1322 c.cancelClaim(suppressed)
M internal/core/datadrag.go +15 -0
@@ -94,6 +94,21 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
9494 return nil
9595 }
9696
97+// dataDragHitChanged excludes notifications that only change metadata,
98+// capture state or the drag icon. The type switch allocates nothing.
99+func dataDragHitChanged(ev ports.ClientEvent) bool {
100+ switch ev.(type) {
101+ case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102+ ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103+ ports.OutputPower, ports.WindowParent, ports.WindowModal,
104+ ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105+ ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106+ return true
107+ default:
108+ return false
109+ }
110+}
111+
97112 // dataDragRetarget re-routes a still cursor: the target is told only when
98113 // it or the point in it changed.
99114 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
@@ -94,6 +94,21 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
94 return nil94 return nil
95 }95 }
96 96
97+// dataDragHitChanged excludes notifications that only change metadata,
98+// capture state or the drag icon. The type switch allocates nothing.
99+func dataDragHitChanged(ev ports.ClientEvent) bool {
100+ switch ev.(type) {
101+ case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102+ ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103+ ports.OutputPower, ports.WindowParent, ports.WindowModal,
104+ ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105+ ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106+ return true
107+ default:
108+ return false
109+ }
110+}
111+
97 // dataDragRetarget re-routes a still cursor: the target is told only when112 // dataDragRetarget re-routes a still cursor: the target is told only when
98 // it or the point in it changed.113 // it or the point in it changed.
99 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {114 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
A internal/core/datadrag_alloc_test.go +34 -0
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestDataDragUnchangedRetargetAllocations(t *testing.T) {
11+ c := publishRig(t, 1)
12+ c.cursorX, c.cursorY = 20, 30
13+ id, x, y := c.hit(c.cursorX, c.cursorY)
14+ if id == 0 {
15+ t.Fatal("no target")
16+ }
17+ c.dataDrag = &dataDragState{target: id, at: [2]float64{x, y}}
18+ ctx := context.Background()
19+ var ev ports.ClientEvent = ports.InputRegionChanged{ID: id, All: true}
20+ if n := testing.AllocsPerRun(1000, func() {
21+ if dataDragHitChanged(ev) {
22+ if err := c.dataDragRetarget(ctx, 0); err != nil {
23+ panic(err)
24+ }
25+ }
26+ }); n != 0 {
27+ t.Fatalf("unchanged retarget: %g allocations, want 0", n)
28+ }
29+ for _, ev := range []ports.ClientEvent{ports.DataDragIcon{}, ports.WindowAppID{}, ports.CaptureSessionOpen{}, ports.PointerConstrained{}} {
30+ if dataDragHitChanged(ev) {
31+ t.Fatalf("rehit for irrelevant event %T", ev)
32+ }
33+ }
34+}
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestDataDragUnchangedRetargetAllocations(t *testing.T) {
11+ c := publishRig(t, 1)
12+ c.cursorX, c.cursorY = 20, 30
13+ id, x, y := c.hit(c.cursorX, c.cursorY)
14+ if id == 0 {
15+ t.Fatal("no target")
16+ }
17+ c.dataDrag = &dataDragState{target: id, at: [2]float64{x, y}}
18+ ctx := context.Background()
19+ var ev ports.ClientEvent = ports.InputRegionChanged{ID: id, All: true}
20+ if n := testing.AllocsPerRun(1000, func() {
21+ if dataDragHitChanged(ev) {
22+ if err := c.dataDragRetarget(ctx, 0); err != nil {
23+ panic(err)
24+ }
25+ }
26+ }); n != 0 {
27+ t.Fatalf("unchanged retarget: %g allocations, want 0", n)
28+ }
29+ for _, ev := range []ports.ClientEvent{ports.DataDragIcon{}, ports.WindowAppID{}, ports.CaptureSessionOpen{}, ports.PointerConstrained{}} {
30+ if dataDragHitChanged(ev) {
31+ t.Fatalf("rehit for irrelevant event %T", ev)
32+ }
33+ }
34+}
M internal/core/datadrag_test.go +12 -0
@@ -575,3 +575,15 @@ func TestDataDragIgnoresCompositorSwipes(t *testing.T) {
575575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)
576576 }
577577 }
578+
579+func TestDataDragRehitsAfterInputRegionChange(t *testing.T) {
580+ d := startDnd(t)
581+ d.start(t, 0)
582+ // The scene is settled and the cursor never moves after drag start.
583+ d.client <- ports.InputRegionChanged{ID: 1}
584+ d.settle(t)
585+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: time.Millisecond})
586+ d.client <- ports.InputRegionChanged{ID: 1, All: true}
587+ d.settle(t)
588+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
589+}
@@ -575,3 +575,15 @@ func TestDataDragIgnoresCompositorSwipes(t *testing.T) {
575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)
576 }576 }
577 }577 }
578+
579+func TestDataDragRehitsAfterInputRegionChange(t *testing.T) {
580+ d := startDnd(t)
581+ d.start(t, 0)
582+ // The scene is settled and the cursor never moves after drag start.
583+ d.client <- ports.InputRegionChanged{ID: 1}
584+ d.settle(t)
585+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: time.Millisecond})
586+ d.client <- ports.InputRegionChanged{ID: 1, All: true}
587+ d.settle(t)
588+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
589+}
M internal/core/flash.go +35 -33
@@ -87,30 +87,9 @@ func (c *Core) flashTick() bool {
8787 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
8888 return false
8989 }
90- layout := sc.mon.Layout()
91- var rect Rect
92- if target.level == flashMonitor {
93- o := sc.mon.Output()
94- rect = Rect{W: o.W, H: o.H}
95- } else {
96- at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 })
97- if at < 0 || layout[at].Fullscreen {
98- return false
99- }
100- rect = layout[at].Rect
101- if target.level == flashColumn {
102- w := sc.mon.Current()
103- col := w.columnOf(target.window)
104- if col < 0 {
105- return false
106- }
107- for _, p := range layout {
108- if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
109- continue
110- }
111- rect = union(rect, p.Rect)
112- }
113- }
90+ rect, ok := c.flashRect(target, sc)
91+ if !ok {
92+ return false
11493 }
11594 if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {
11695 return false
@@ -139,7 +118,7 @@ func (c *Core) advanceFlash(only *screen) {
139118 return
140119 }
141120 sc := c.screens[c.screenIndex(f.running.output)]
142- if !c.flashVisible(*f.running, sc) {
121+ if rect, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect {
143122 c.stopFlash()
144123 return
145124 }
@@ -156,27 +135,50 @@ func (c *Core) advanceFlash(only *screen) {
156135 f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))
157136 }
158137
159-// flashVisible checks the current layout, not leaving placements retained for
160-// transitions: an old border must not outlive its target's visibility.
161-func (c *Core) flashVisible(target flashTarget, sc *screen) bool {
138+// flashRect resolves the current target, never retaining a border after its
139+// visibility or geometry changes. stopFlash leaves the published sample intact
140+// so the scene diff also damages the old border.
141+func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, bool) {
162142 if sc.mon.ov.open {
163- return false
143+ return Rect{}, false
164144 }
165145 if target.level == flashMonitor {
166- return true
146+ o := sc.mon.Output()
147+ return Rect{W: o.W, H: o.H}, true
167148 }
168149 owner, _ := c.screenOf(target.window)
169150 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
170- return false
151+ return Rect{}, false
171152 }
172153 view := sc.mon.viewport()
173154 c.flash.layout = sc.mon.layoutInto(c.flash.layout)
155+ var rect Rect
156+ found := false
174157 for _, p := range c.flash.layout {
175158 if p.ID == target.window {
176- return !p.Fullscreen && p.Preview == 0 && view.shows(p)
159+ if p.Fullscreen || p.Preview != 0 || !view.shows(p) {
160+ return Rect{}, false
161+ }
162+ rect, found = p.Rect, true
163+ break
164+ }
165+ }
166+ if !found {
167+ return Rect{}, false
168+ }
169+ if target.level == flashColumn {
170+ w := sc.mon.Current()
171+ col := w.columnOf(target.window)
172+ if col < 0 {
173+ return Rect{}, false
174+ }
175+ for _, p := range c.flash.layout {
176+ if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {
177+ rect = union(rect, p.Rect)
178+ }
177179 }
178180 }
179- return false
181+ return rect, true
180182 }
181183
182184 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
@@ -87,30 +87,9 @@ func (c *Core) flashTick() bool {
87 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)87 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
88 return false88 return false
89 }89 }
90- layout := sc.mon.Layout()90+ rect, ok := c.flashRect(target, sc)
91- var rect Rect91+ if !ok {
92- if target.level == flashMonitor {92+ return false
93- o := sc.mon.Output()
94- rect = Rect{W: o.W, H: o.H}
95- } else {
96- at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 })
97- if at < 0 || layout[at].Fullscreen {
98- return false
99- }
100- rect = layout[at].Rect
101- if target.level == flashColumn {
102- w := sc.mon.Current()
103- col := w.columnOf(target.window)
104- if col < 0 {
105- return false
106- }
107- for _, p := range layout {
108- if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
109- continue
110- }
111- rect = union(rect, p.Rect)
112- }
113- }
114 }93 }
115 if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {94 if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {
116 return false95 return false
@@ -139,7 +118,7 @@ func (c *Core) advanceFlash(only *screen) {
139 return118 return
140 }119 }
141 sc := c.screens[c.screenIndex(f.running.output)]120 sc := c.screens[c.screenIndex(f.running.output)]
142- if !c.flashVisible(*f.running, sc) {121+ if rect, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect {
143 c.stopFlash()122 c.stopFlash()
144 return123 return
145 }124 }
@@ -156,27 +135,50 @@ func (c *Core) advanceFlash(only *screen) {
156 f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))135 f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))
157 }136 }
158 137
159-// flashVisible checks the current layout, not leaving placements retained for138+// flashRect resolves the current target, never retaining a border after its
160-// transitions: an old border must not outlive its target's visibility.139+// visibility or geometry changes. stopFlash leaves the published sample intact
161-func (c *Core) flashVisible(target flashTarget, sc *screen) bool {140+// so the scene diff also damages the old border.
141+func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, bool) {
162 if sc.mon.ov.open {142 if sc.mon.ov.open {
163- return false143+ return Rect{}, false
164 }144 }
165 if target.level == flashMonitor {145 if target.level == flashMonitor {
166- return true146+ o := sc.mon.Output()
147+ return Rect{W: o.W, H: o.H}, true
167 }148 }
168 owner, _ := c.screenOf(target.window)149 owner, _ := c.screenOf(target.window)
169 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {150 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
170- return false151+ return Rect{}, false
171 }152 }
172 view := sc.mon.viewport()153 view := sc.mon.viewport()
173 c.flash.layout = sc.mon.layoutInto(c.flash.layout)154 c.flash.layout = sc.mon.layoutInto(c.flash.layout)
155+ var rect Rect
156+ found := false
174 for _, p := range c.flash.layout {157 for _, p := range c.flash.layout {
175 if p.ID == target.window {158 if p.ID == target.window {
176- return !p.Fullscreen && p.Preview == 0 && view.shows(p)159+ if p.Fullscreen || p.Preview != 0 || !view.shows(p) {
160+ return Rect{}, false
161+ }
162+ rect, found = p.Rect, true
163+ break
164+ }
165+ }
166+ if !found {
167+ return Rect{}, false
168+ }
169+ if target.level == flashColumn {
170+ w := sc.mon.Current()
171+ col := w.columnOf(target.window)
172+ if col < 0 {
173+ return Rect{}, false
174+ }
175+ for _, p := range c.flash.layout {
176+ if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {
177+ rect = union(rect, p.Rect)
178+ }
177 }179 }
178 }180 }
179- return false181+ return rect, true
180 }182 }
181 183
182 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {184 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
M internal/core/flash_test.go +47 -0
@@ -670,3 +670,50 @@ func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
670670 }
671671 }
672672 }
673+
674+func TestFlashCancelsOnGeometryChange(t *testing.T) {
675+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
676+ for _, change := range []string{"resize", "new window"} {
677+ t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
678+ c, clock := flashCore(t)
679+ startFlash(t, c, clock, level, 3)
680+ before := indicatorScene(t, c)
681+ if before.Flash == nil {
682+ t.Fatal("setup: no flash")
683+ }
684+ if level == flashMonitor {
685+ c.cur().mon.SetOutput(400, 300)
686+ } else if change == "resize" {
687+ c.cur().mon.Current().Apply(ActionCycleColumnWidth)
688+ } else {
689+ c.cur().mon.AddWindow(4)
690+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
691+ }
692+ after := indicatorScene(t, c)
693+ if after.Flash != nil || c.flash.running != nil {
694+ t.Fatal("stale geometry retained")
695+ }
696+ if before.Flash.Rect != c.flash.published.Rect {
697+ t.Fatal("old flash sample mutated")
698+ }
699+ })
700+ }
701+ }
702+}
703+
704+func TestFlashGeometryRevalidationAllocations(t *testing.T) {
705+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
706+ t.Run(string(rune('0'+level)), func(t *testing.T) {
707+ c, clock := flashCore(t)
708+ startFlash(t, c, clock, level, 3)
709+ // Measure production work, not the generated clock mock's call log.
710+ c.opts.Clock = nil
711+ c.flash.start = time.Now()
712+ c.cfg.Focus.FlashDuration = time.Hour
713+ c.advanceFlash(nil) // warm owner-owned layout scratch
714+ if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
715+ t.Fatalf("flash geometry validation allocates %v times per frame", n)
716+ }
717+ })
718+ }
719+}
@@ -670,3 +670,50 @@ func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
670 }670 }
671 }671 }
672 }672 }
673+
674+func TestFlashCancelsOnGeometryChange(t *testing.T) {
675+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
676+ for _, change := range []string{"resize", "new window"} {
677+ t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
678+ c, clock := flashCore(t)
679+ startFlash(t, c, clock, level, 3)
680+ before := indicatorScene(t, c)
681+ if before.Flash == nil {
682+ t.Fatal("setup: no flash")
683+ }
684+ if level == flashMonitor {
685+ c.cur().mon.SetOutput(400, 300)
686+ } else if change == "resize" {
687+ c.cur().mon.Current().Apply(ActionCycleColumnWidth)
688+ } else {
689+ c.cur().mon.AddWindow(4)
690+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
691+ }
692+ after := indicatorScene(t, c)
693+ if after.Flash != nil || c.flash.running != nil {
694+ t.Fatal("stale geometry retained")
695+ }
696+ if before.Flash.Rect != c.flash.published.Rect {
697+ t.Fatal("old flash sample mutated")
698+ }
699+ })
700+ }
701+ }
702+}
703+
704+func TestFlashGeometryRevalidationAllocations(t *testing.T) {
705+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
706+ t.Run(string(rune('0'+level)), func(t *testing.T) {
707+ c, clock := flashCore(t)
708+ startFlash(t, c, clock, level, 3)
709+ // Measure production work, not the generated clock mock's call log.
710+ c.opts.Clock = nil
711+ c.flash.start = time.Now()
712+ c.cfg.Focus.FlashDuration = time.Hour
713+ c.advanceFlash(nil) // warm owner-owned layout scratch
714+ if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
715+ t.Fatalf("flash geometry validation allocates %v times per frame", n)
716+ }
717+ })
718+ }
719+}
M internal/core/hint.go +26 -16
@@ -33,11 +33,17 @@ const (
3333 type hintTarget struct {
3434 x, y float64
3535 window WindowID
36+ source hintSourceGeom // source window and its request-time client geometry
3637 label string
3738 rect ports.Rect
3839 text bool // found by OCR
3940 }
4041
42+type hintSourceGeom struct {
43+ id WindowID
44+ rect ports.Rect
45+}
46+
4147 type hintState struct {
4248 phase hintPhase
4349 seq uint64 // last request sequence number
@@ -161,10 +167,11 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
161167 // A layer surface, or a window of another output.
162168 continue
163169 }
164- if !c.hintOwned(sc, t, root) {
170+ source, ok := c.hintSource(sc, t, root)
171+ if !ok {
165172 continue
166173 }
167- kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
174+ kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, text: t.Text})
168175 }
169176 if len(kept) == 0 {
170177 c.hintCancel()
@@ -188,22 +195,24 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
188195 return true
189196 }
190197
191-// hintOwned reports whether the window under a target is the one it was
192-// scanned on. An accessibility target names its window; an OCR target only
193-// counts over a window that was visible when the scan was requested and has
194-// not moved or resized since.
195-func (c *Core) hintOwned(sc *screen, t ports.HintTarget, root WindowID) bool {
196- if t.Window != 0 {
197- return root == t.Window
198+// hintSource resolves the request-time source window only while its client
199+// geometry is unchanged. This applies equally to accessibility and OCR targets.
200+func (c *Core) hintSource(sc *screen, t ports.HintTarget, root WindowID) (hintSourceGeom, bool) {
201+ if t.Window != 0 && root != t.Window {
202+ return hintSourceGeom{}, false
198203 }
199204 for _, w := range c.hint.windows {
200- if w.ID != root {
201- continue
205+ if w.ID == root && c.hintSourceUnchanged(sc, hintSourceGeom{id: w.ID, rect: w.Rect}) {
206+ return hintSourceGeom{id: w.ID, rect: w.Rect}, true
202207 }
203- for _, p := range sc.shownLayout() {
204- if p.ID == root {
205- return c.clientRect(p) == w.Rect
206- }
208+ }
209+ return hintSourceGeom{}, false
210+}
211+
212+func (c *Core) hintSourceUnchanged(sc *screen, w hintSourceGeom) bool {
213+ for _, p := range sc.shownLayout() {
214+ if p.ID == w.id {
215+ return c.clientRect(p) == w.rect
207216 }
208217 }
209218 return false
@@ -340,7 +349,8 @@ func (c *Core) hintClickable(t hintTarget) bool {
340349 return false
341350 }
342351 id, _, _ := c.hit(t.x, t.y)
343- return id == t.window
352+ sc, _ := c.screenOf(t.source.id)
353+ return id == t.window && sc != nil && c.hintSourceUnchanged(sc, t.source)
344354 }
345355
346356 // hintLabels are the labels to draw on sc: those still matching what was
@@ -33,11 +33,17 @@ const (
33 type hintTarget struct {33 type hintTarget struct {
34 x, y float6434 x, y float64
35 window WindowID35 window WindowID
36+ source hintSourceGeom // source window and its request-time client geometry
36 label string37 label string
37 rect ports.Rect38 rect ports.Rect
38 text bool // found by OCR39 text bool // found by OCR
39 }40 }
40 41
42+type hintSourceGeom struct {
43+ id WindowID
44+ rect ports.Rect
45+}
46+
41 type hintState struct {47 type hintState struct {
42 phase hintPhase48 phase hintPhase
43 seq uint64 // last request sequence number49 seq uint64 // last request sequence number
@@ -161,10 +167,11 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
161 // A layer surface, or a window of another output.167 // A layer surface, or a window of another output.
162 continue168 continue
163 }169 }
164- if !c.hintOwned(sc, t, root) {170+ source, ok := c.hintSource(sc, t, root)
171+ if !ok {
165 continue172 continue
166 }173 }
167- kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})174+ kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, text: t.Text})
168 }175 }
169 if len(kept) == 0 {176 if len(kept) == 0 {
170 c.hintCancel()177 c.hintCancel()
@@ -188,22 +195,24 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
188 return true195 return true
189 }196 }
190 197
191-// hintOwned reports whether the window under a target is the one it was198+// hintSource resolves the request-time source window only while its client
192-// scanned on. An accessibility target names its window; an OCR target only199+// geometry is unchanged. This applies equally to accessibility and OCR targets.
193-// counts over a window that was visible when the scan was requested and has200+func (c *Core) hintSource(sc *screen, t ports.HintTarget, root WindowID) (hintSourceGeom, bool) {
194-// not moved or resized since.201+ if t.Window != 0 && root != t.Window {
195-func (c *Core) hintOwned(sc *screen, t ports.HintTarget, root WindowID) bool {202+ return hintSourceGeom{}, false
196- if t.Window != 0 {
197- return root == t.Window
198 }203 }
199 for _, w := range c.hint.windows {204 for _, w := range c.hint.windows {
200- if w.ID != root {205+ if w.ID == root && c.hintSourceUnchanged(sc, hintSourceGeom{id: w.ID, rect: w.Rect}) {
201- continue206+ return hintSourceGeom{id: w.ID, rect: w.Rect}, true
202 }207 }
203- for _, p := range sc.shownLayout() {208+ }
204- if p.ID == root {209+ return hintSourceGeom{}, false
205- return c.clientRect(p) == w.Rect210+}
206- }211+
212+func (c *Core) hintSourceUnchanged(sc *screen, w hintSourceGeom) bool {
213+ for _, p := range sc.shownLayout() {
214+ if p.ID == w.id {
215+ return c.clientRect(p) == w.rect
207 }216 }
208 }217 }
209 return false218 return false
@@ -340,7 +349,8 @@ func (c *Core) hintClickable(t hintTarget) bool {
340 return false349 return false
341 }350 }
342 id, _, _ := c.hit(t.x, t.y)351 id, _, _ := c.hit(t.x, t.y)
343- return id == t.window352+ sc, _ := c.screenOf(t.source.id)
353+ return id == t.window && sc != nil && c.hintSourceUnchanged(sc, t.source)
344 }354 }
345 355
346 // hintLabels are the labels to draw on sc: those still matching what was356 // hintLabels are the labels to draw on sc: those still matching what was
M internal/core/hint_test.go +46 -0
@@ -832,3 +832,49 @@ func TestHintDisabledNotesOnce(t *testing.T) {
832832 t.Fatalf("disabled note ran %d times, want 1", got)
833833 }
834834 }
835+
836+// Resizing window 1 leaves the old target centre inside it. Identity alone
837+// therefore cannot protect either admission or the eventual synthetic click.
838+func TestHintRejectsResizedSource(t *testing.T) {
839+ for _, accessibility := range []bool{false, true} {
840+ for _, beforeResult := range []bool{false, true} {
841+ t.Run(fmt.Sprintf("accessibility=%v/beforeResult=%v", accessibility, beforeResult), func(t *testing.T) {
842+ h := newHint(t, core.Options{})
843+ tg := h.inside(0)
844+ tg.Text = !accessibility
845+ if accessibility {
846+ tg.Window = 1
847+ }
848+ req := h.start()
849+ if !beforeResult {
850+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
851+ h.labelled(1, 0)
852+ }
853+ h.client <- ports.WindowMapped{ID: 2}
854+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
855+ var resized ports.Rect
856+ for _, w := range s.Windows {
857+ if w.ID == 1 {
858+ resized = w.Rect
859+ }
860+ }
861+ x, y := tg.Rect.X+tg.Rect.W/2, tg.Rect.Y+tg.Rect.H/2
862+ if resized == h.win || x < resized.X || x >= resized.X+resized.W || y < resized.Y || y >= resized.Y+resized.H {
863+ t.Fatalf("setup: old target %+v not inside resized window %+v (was %+v)", tg.Rect, resized, h.win)
864+ }
865+ if beforeResult {
866+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
867+ h.labelled(0, 0)
868+ } else {
869+ h.drain()
870+ h.press("f", 0)
871+ for _, cmd := range h.barrier() {
872+ if _, ok := cmd.(ports.PointerButtonTo); ok {
873+ t.Fatal("clicked resized source window")
874+ }
875+ }
876+ }
877+ })
878+ }
879+ }
880+}
@@ -832,3 +832,49 @@ func TestHintDisabledNotesOnce(t *testing.T) {
832 t.Fatalf("disabled note ran %d times, want 1", got)832 t.Fatalf("disabled note ran %d times, want 1", got)
833 }833 }
834 }834 }
835+
836+// Resizing window 1 leaves the old target centre inside it. Identity alone
837+// therefore cannot protect either admission or the eventual synthetic click.
838+func TestHintRejectsResizedSource(t *testing.T) {
839+ for _, accessibility := range []bool{false, true} {
840+ for _, beforeResult := range []bool{false, true} {
841+ t.Run(fmt.Sprintf("accessibility=%v/beforeResult=%v", accessibility, beforeResult), func(t *testing.T) {
842+ h := newHint(t, core.Options{})
843+ tg := h.inside(0)
844+ tg.Text = !accessibility
845+ if accessibility {
846+ tg.Window = 1
847+ }
848+ req := h.start()
849+ if !beforeResult {
850+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
851+ h.labelled(1, 0)
852+ }
853+ h.client <- ports.WindowMapped{ID: 2}
854+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
855+ var resized ports.Rect
856+ for _, w := range s.Windows {
857+ if w.ID == 1 {
858+ resized = w.Rect
859+ }
860+ }
861+ x, y := tg.Rect.X+tg.Rect.W/2, tg.Rect.Y+tg.Rect.H/2
862+ if resized == h.win || x < resized.X || x >= resized.X+resized.W || y < resized.Y || y >= resized.Y+resized.H {
863+ t.Fatalf("setup: old target %+v not inside resized window %+v (was %+v)", tg.Rect, resized, h.win)
864+ }
865+ if beforeResult {
866+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
867+ h.labelled(0, 0)
868+ } else {
869+ h.drain()
870+ h.press("f", 0)
871+ for _, cmd := range h.barrier() {
872+ if _, ok := cmd.(ports.PointerButtonTo); ok {
873+ t.Fatal("clicked resized source window")
874+ }
875+ }
876+ }
877+ })
878+ }
879+ }
880+}
M internal/core/outbox.go +3 -4
@@ -51,8 +51,7 @@ type CommandStats struct {
5151 // MaxBacklog is the longest queue seen.
5252 MaxBacklog uint64
5353 // MaxQueueWait is the longest time a queued command took to reach the
54- // channel; MaxFlushWait the longest time core waited in flush (a spawn's
55- // SlotsPending, or a full outbox).
54+ // channel; MaxFlushWait the longest time core waited for outbox room.
5655 MaxQueueWait, MaxFlushWait time.Duration
5756 }
5857
@@ -61,6 +60,7 @@ func (o *outbox) takeStats() CommandStats {
6160 o.mu.Lock()
6261 defer o.mu.Unlock()
6362 s := o.stats
63+ s.MaxBacklog = max(s.MaxBacklog, o.pushed-o.sent)
6464 o.stats = CommandStats{}
6565 return s
6666 }
@@ -171,8 +171,7 @@ func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, sta
171171 }
172172
173173 // flush waits until the entry seq is in out, or ctx ends. It is for the
174-// rare command another channel depends on (SlotsPending before a spawn);
175-// everything else never waits. Without a running sender (core driven
174+// bounded outbox backpressure. Without a running sender (core driven
176175 // without Run), it delivers the queue itself, in order.
177176 func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error {
178177 var start time.Time
@@ -51,8 +51,7 @@ type CommandStats struct {
51 // MaxBacklog is the longest queue seen.51 // MaxBacklog is the longest queue seen.
52 MaxBacklog uint6452 MaxBacklog uint64
53 // MaxQueueWait is the longest time a queued command took to reach the53 // MaxQueueWait is the longest time a queued command took to reach the
54- // channel; MaxFlushWait the longest time core waited in flush (a spawn's54+ // channel; MaxFlushWait the longest time core waited for outbox room.
55- // SlotsPending, or a full outbox).
56 MaxQueueWait, MaxFlushWait time.Duration55 MaxQueueWait, MaxFlushWait time.Duration
57 }56 }
58 57
@@ -61,6 +60,7 @@ func (o *outbox) takeStats() CommandStats {
61 o.mu.Lock()60 o.mu.Lock()
62 defer o.mu.Unlock()61 defer o.mu.Unlock()
63 s := o.stats62 s := o.stats
63+ s.MaxBacklog = max(s.MaxBacklog, o.pushed-o.sent)
64 o.stats = CommandStats{}64 o.stats = CommandStats{}
65 return s65 return s
66 }66 }
@@ -171,8 +171,7 @@ func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, sta
171 }171 }
172 172
173 // flush waits until the entry seq is in out, or ctx ends. It is for the173 // flush waits until the entry seq is in out, or ctx ends. It is for the
174-// rare command another channel depends on (SlotsPending before a spawn);174+// bounded outbox backpressure. Without a running sender (core driven
175-// everything else never waits. Without a running sender (core driven
176 // without Run), it delivers the queue itself, in order.175 // without Run), it delivers the queue itself, in order.
177 func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error {176 func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error {
178 var start time.Time177 var start time.Time
M internal/core/outbox_test.go +20 -0
@@ -210,3 +210,23 @@ func TestOutboxQueueWaitIsPerEntry(t *testing.T) {
210210 }
211211 })
212212 }
213+
214+// A stalled sender must remain visible in every reporting period, including
215+// when it has taken the queue into its private delivery batch.
216+func TestOutboxStatsSampleOutstandingBacklog(t *testing.T) {
217+ o := newOutbox()
218+ out := make(chan ports.ClientCommand)
219+ for range outboxLimit {
220+ o.push(out, ports.FocusWindow{ID: 1}, ports.SecurityState{}, false)
221+ }
222+ batch := o.take()
223+ for range 2 {
224+ if got := o.takeStats().MaxBacklog; got != outboxLimit {
225+ t.Fatalf("backlog %d, want %d", got, outboxLimit)
226+ }
227+ }
228+ o.recycle(batch)
229+ if got := o.takeStats().MaxBacklog; got != 0 {
230+ t.Fatalf("recovered backlog %d", got)
231+ }
232+}
@@ -210,3 +210,23 @@ func TestOutboxQueueWaitIsPerEntry(t *testing.T) {
210 }210 }
211 })211 })
212 }212 }
213+
214+// A stalled sender must remain visible in every reporting period, including
215+// when it has taken the queue into its private delivery batch.
216+func TestOutboxStatsSampleOutstandingBacklog(t *testing.T) {
217+ o := newOutbox()
218+ out := make(chan ports.ClientCommand)
219+ for range outboxLimit {
220+ o.push(out, ports.FocusWindow{ID: 1}, ports.SecurityState{}, false)
221+ }
222+ batch := o.take()
223+ for range 2 {
224+ if got := o.takeStats().MaxBacklog; got != outboxLimit {
225+ t.Fatalf("backlog %d, want %d", got, outboxLimit)
226+ }
227+ }
228+ o.recycle(batch)
229+ if got := o.takeStats().MaxBacklog; got != 0 {
230+ t.Fatalf("recovered backlog %d", got)
231+ }
232+}
M internal/core/screens_test.go +0 -3
@@ -647,9 +647,6 @@ func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
647647 case <-time.After(time.Second):
648648 t.Fatal("no output command or spawn")
649649 }
650- if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
651- t.Fatalf("expected pending before spawn, got %v", v)
652- }
653650 if req := receive(t, spawn); len(req.Argv) == 0 {
654651 t.Fatal(req)
655652 }
@@ -647,9 +647,6 @@ func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
647 case <-time.After(time.Second):647 case <-time.After(time.Second):
648 t.Fatal("no output command or spawn")648 t.Fatal("no output command or spawn")
649 }649 }
650- if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
651- t.Fatalf("expected pending before spawn, got %v", v)
652- }
653 if req := receive(t, spawn); len(req.Argv) == 0 {650 if req := receive(t, spawn); len(req.Argv) == 0 {
654 t.Fatal(req)651 t.Fatal(req)
655 }652 }
M internal/core/security_review_test.go +2 -7
@@ -77,7 +77,7 @@ func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
7777 }
7878 }
7979
80-func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
80+func TestEpochChangeSuppressesSpawnAndDefersSlots(t *testing.T) {
8181 c, _, _ := hiddenCaptureCommands(t)
8282 key := slotKey{workspace: "dev", index: 1}
8383 c.slots[key] = &slotState{argv: []string{"terminal"}}
@@ -100,10 +100,6 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
100100 spawn := make(chan ports.SpawnRequest, 2)
101101 c.ch.Spawn = spawn
102102 err := c.spawnSlots(context.Background(), false)
103- cmd := reviewReceive(t, commands).(ports.SecurityCommand)
104- if _, ok := cmd.Command.(ports.SlotsPending); !ok {
105- t.Fatal(cmd)
106- }
107103 if err != nil {
108104 t.Fatal(err)
109105 }
@@ -122,7 +118,7 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
122118 }
123119 }
124120
125-func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T) {
121+func TestTerminalEpochChangeSuppressesSpawnAndRestoresRetry(t *testing.T) {
126122 c, _, _ := hiddenCaptureCommands(t)
127123 c.cur().mon.RemoveWindow(1)
128124 c.cur().mon.RemoveWindow(2)
@@ -147,7 +143,6 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
147143 spawn := make(chan ports.SpawnRequest, 1)
148144 c.ch.Spawn = spawn
149145 err := c.spawnEmpty(context.Background())
150- reviewReceive(t, commands)
151146 if err != nil {
152147 t.Fatal(err)
153148 }
@@ -77,7 +77,7 @@ func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
77 }77 }
78 }78 }
79 79
80-func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {80+func TestEpochChangeSuppressesSpawnAndDefersSlots(t *testing.T) {
81 c, _, _ := hiddenCaptureCommands(t)81 c, _, _ := hiddenCaptureCommands(t)
82 key := slotKey{workspace: "dev", index: 1}82 key := slotKey{workspace: "dev", index: 1}
83 c.slots[key] = &slotState{argv: []string{"terminal"}}83 c.slots[key] = &slotState{argv: []string{"terminal"}}
@@ -100,10 +100,6 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
100 spawn := make(chan ports.SpawnRequest, 2)100 spawn := make(chan ports.SpawnRequest, 2)
101 c.ch.Spawn = spawn101 c.ch.Spawn = spawn
102 err := c.spawnSlots(context.Background(), false)102 err := c.spawnSlots(context.Background(), false)
103- cmd := reviewReceive(t, commands).(ports.SecurityCommand)
104- if _, ok := cmd.Command.(ports.SlotsPending); !ok {
105- t.Fatal(cmd)
106- }
107 if err != nil {103 if err != nil {
108 t.Fatal(err)104 t.Fatal(err)
109 }105 }
@@ -122,7 +118,7 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
122 }118 }
123 }119 }
124 120
125-func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T) {121+func TestTerminalEpochChangeSuppressesSpawnAndRestoresRetry(t *testing.T) {
126 c, _, _ := hiddenCaptureCommands(t)122 c, _, _ := hiddenCaptureCommands(t)
127 c.cur().mon.RemoveWindow(1)123 c.cur().mon.RemoveWindow(1)
128 c.cur().mon.RemoveWindow(2)124 c.cur().mon.RemoveWindow(2)
@@ -147,7 +143,6 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
147 spawn := make(chan ports.SpawnRequest, 1)143 spawn := make(chan ports.SpawnRequest, 1)
148 c.ch.Spawn = spawn144 c.ch.Spawn = spawn
149 err := c.spawnEmpty(context.Background())145 err := c.spawnEmpty(context.Background())
150- reviewReceive(t, commands)
151 if err != nil {146 if err != nil {
152 t.Fatal(err)147 t.Fatal(err)
153 }148 }
M internal/core/spawn_placement_test.go +1 -10
@@ -1,7 +1,6 @@
11 package core
22
33 import (
4- "context"
54 "strings"
65 "testing"
76 "time"
@@ -32,15 +31,7 @@ func TestSlotClaimWithoutWorkspace(t *testing.T) {
3231 if s, _ := c.screenOf(21); s == nil {
3332 t.Fatal("window did not map normally")
3433 }
35- // Claim status is also reflected on the client command port.
36- commands := make(chan ports.ClientCommand, 1)
37- c.ch.Commands = commands
38- if err := c.publishPending(context.Background()); err != nil {
39- t.Fatal(err)
40- }
41- if got := (<-commands).(ports.SlotsPending); !got.Pending {
42- t.Fatal("pending claim was not retained")
43- }
34+
4435 }
4536
4637 func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
@@ -1,7 +1,6 @@
1 package core1 package core
2 2
3 import (3 import (
4- "context"
5 "strings"4 "strings"
6 "testing"5 "testing"
7 "time"6 "time"
@@ -32,15 +31,7 @@ func TestSlotClaimWithoutWorkspace(t *testing.T) {
32 if s, _ := c.screenOf(21); s == nil {31 if s, _ := c.screenOf(21); s == nil {
33 t.Fatal("window did not map normally")32 t.Fatal("window did not map normally")
34 }33 }
35- // Claim status is also reflected on the client command port.34+
36- commands := make(chan ports.ClientCommand, 1)
37- c.ch.Commands = commands
38- if err := c.publishPending(context.Background()); err != nil {
39- t.Fatal(err)
40- }
41- if got := (<-commands).(ports.SlotsPending); !got.Pending {
42- t.Fatal("pending claim was not retained")
43- }
44 }35 }
45 36
46 func TestExpiredTerminalTokenMapsNormally(t *testing.T) {37 func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
M internal/core/stash_spawn_internal_test.go +17 -58
@@ -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"
@@ -40,46 +39,23 @@ func claimToken(req ports.SpawnRequest) string {
4039 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
4140 }
4241
43-// Wayland reads tokens only while slots are pending, so the flag is in the
44-// command channel before the spawn can reach the launcher, also when the
45-// display reads nothing: in a synctest bubble the sender is waited on until
46-// it blocks on the unread channel, and the spawn channel must still be empty.
47-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- c.out.start()
56- go c.out.run(ctx, commands)
57- done := make(chan error, 1)
58- go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
59- synctest.Wait()
60- if len(spawn) != 0 {
61- t.Fatal("spawned before the pending flag was delivered")
62- }
63- select {
64- case err := <-done:
65- t.Fatalf("spawnStash returned before the flag was read: %v", err)
66- default:
67- }
68- if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
69- t.Fatal("first command is not SlotsPending{true}")
70- }
71- synctest.Wait()
72- if err := <-done; err != nil {
73- t.Fatal(err)
74- }
75- if len(spawn) != 1 {
76- t.Fatal("no spawn after the flag")
77- }
78- })
42+// A stalled command transport must not delay a placement spawn.
43+func TestSpawnStashIndependentOfCommandDrain(t *testing.T) {
44+ now := claimBase
45+ commands := make(chan ports.ClientCommand)
46+ spawn := make(chan ports.SpawnRequest, 1)
47+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
48+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false); err != nil {
49+ t.Fatal(err)
50+ }
51+ req := <-spawn
52+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
53+ if w := c.cur().mon.Current(); len(w.Stash) != 1 || w.Stash[0].ID != 5 {
54+ t.Fatal("placement lost while commands unread")
55+ }
7956 }
8057
81-// A send the launcher queue suppresses leaves no claim behind, and the
82-// pending flag falls again at the next publish.
58+// A send the launcher queue suppresses leaves no claim behind.
8359 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
8460 now := claimBase
8561 commands := make(chan ports.ClientCommand, 8)
@@ -90,15 +66,6 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
9066 if c.placement.anyPending() {
9167 t.Fatal("suppressed claims stayed pending")
9268 }
93- if v := (<-commands).(ports.SlotsPending); !v.Pending {
94- t.Fatal("pending not announced")
95- }
96- if err := c.publishPending(context.Background()); err != nil {
97- t.Fatal(err)
98- }
99- if v := (<-commands).(ports.SlotsPending); v.Pending {
100- t.Fatal("stale pending flag not republished")
101- }
10269 }
10370
10471 // From the first command that is not issued, the rest are not sent and their
@@ -112,25 +79,17 @@ func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
11279 wantErr error
11380 }{
11481 {"queue of one", 1, false, 1, nil},
115- {"publication fails", 4, true, 0, context.Canceled},
82+ {"context cancelled", 4, true, 0, context.Canceled},
11683 } {
11784 t.Run(tc.name, func(t *testing.T) {
11885 now := claimBase
119- commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
86+ commands := make(chan ports.ClientCommand) // unread: spawns are independent of command delivery
12087 spawn := make(chan ports.SpawnRequest, tc.queue)
12188 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
12289 ctx, cancel := context.WithCancel(context.Background())
12390 t.Cleanup(cancel)
12491 if tc.cancelled {
12592 cancel()
126- } else {
127- // An unread unbuffered channel would block the flag: read it.
128- go func() {
129- select {
130- case <-commands:
131- case <-ctx.Done():
132- }
133- }()
13493 }
13594 err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
13695 if !errors.Is(err, tc.wantErr) {
@@ -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"
@@ -40,46 +39,23 @@ func claimToken(req ports.SpawnRequest) string {
40 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")39 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41 }40 }
42 41
43-// Wayland reads tokens only while slots are pending, so the flag is in the42+// A stalled command transport must not delay a placement spawn.
44-// command channel before the spawn can reach the launcher, also when the43+func TestSpawnStashIndependentOfCommandDrain(t *testing.T) {
45-// display reads nothing: in a synctest bubble the sender is waited on until44+ now := claimBase
46-// it blocks on the unread channel, and the spawn channel must still be empty.45+ commands := make(chan ports.ClientCommand)
47-func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {46+ spawn := make(chan ports.SpawnRequest, 1)
48- synctest.Test(t, func(t *testing.T) {47+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
49- now := claimBase48+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false); err != nil {
50- commands := make(chan ports.ClientCommand)49+ t.Fatal(err)
51- spawn := make(chan ports.SpawnRequest, 1)50+ }
52- c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))51+ req := <-spawn
53- ctx, cancel := context.WithCancel(context.Background())52+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
54- t.Cleanup(cancel)53+ if w := c.cur().mon.Current(); len(w.Stash) != 1 || w.Stash[0].ID != 5 {
55- c.out.start()54+ t.Fatal("placement lost while commands unread")
56- go c.out.run(ctx, commands)55+ }
57- done := make(chan error, 1)
58- go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
59- synctest.Wait()
60- if len(spawn) != 0 {
61- t.Fatal("spawned before the pending flag was delivered")
62- }
63- select {
64- case err := <-done:
65- t.Fatalf("spawnStash returned before the flag was read: %v", err)
66- default:
67- }
68- if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
69- t.Fatal("first command is not SlotsPending{true}")
70- }
71- synctest.Wait()
72- if err := <-done; err != nil {
73- t.Fatal(err)
74- }
75- if len(spawn) != 1 {
76- t.Fatal("no spawn after the flag")
77- }
78- })
79 }56 }
80 57
81-// A send the launcher queue suppresses leaves no claim behind, and the58+// A send the launcher queue suppresses leaves no claim behind.
82-// pending flag falls again at the next publish.
83 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {59 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
84 now := claimBase60 now := claimBase
85 commands := make(chan ports.ClientCommand, 8)61 commands := make(chan ports.ClientCommand, 8)
@@ -90,15 +66,6 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
90 if c.placement.anyPending() {66 if c.placement.anyPending() {
91 t.Fatal("suppressed claims stayed pending")67 t.Fatal("suppressed claims stayed pending")
92 }68 }
93- if v := (<-commands).(ports.SlotsPending); !v.Pending {
94- t.Fatal("pending not announced")
95- }
96- if err := c.publishPending(context.Background()); err != nil {
97- t.Fatal(err)
98- }
99- if v := (<-commands).(ports.SlotsPending); v.Pending {
100- t.Fatal("stale pending flag not republished")
101- }
102 }69 }
103 70
104 // From the first command that is not issued, the rest are not sent and their71 // From the first command that is not issued, the rest are not sent and their
@@ -112,25 +79,17 @@ func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
112 wantErr error79 wantErr error
113 }{80 }{
114 {"queue of one", 1, false, 1, nil},81 {"queue of one", 1, false, 1, nil},
115- {"publication fails", 4, true, 0, context.Canceled},82+ {"context cancelled", 4, true, 0, context.Canceled},
116 } {83 } {
117 t.Run(tc.name, func(t *testing.T) {84 t.Run(tc.name, func(t *testing.T) {
118 now := claimBase85 now := claimBase
119- commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends86+ commands := make(chan ports.ClientCommand) // unread: spawns are independent of command delivery
120 spawn := make(chan ports.SpawnRequest, tc.queue)87 spawn := make(chan ports.SpawnRequest, tc.queue)
121 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))88 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
122 ctx, cancel := context.WithCancel(context.Background())89 ctx, cancel := context.WithCancel(context.Background())
123 t.Cleanup(cancel)90 t.Cleanup(cancel)
124 if tc.cancelled {91 if tc.cancelled {
125 cancel()92 cancel()
126- } else {
127- // An unread unbuffered channel would block the flag: read it.
128- go func() {
129- select {
130- case <-commands:
131- case <-ctx.Done():
132- }
133- }()
134 }93 }
135 err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)94 err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
136 if !errors.Is(err, tc.wantErr) {95 if !errors.Is(err, tc.wantErr) {
M internal/core/stash_spawn_test.go +0 -22
@@ -29,16 +29,6 @@ func slotToken(t *testing.T, req ports.SpawnRequest) string {
2929 return req.Env[0][len(ports.SlotEnv)+1:]
3030 }
3131
32-// pendingSent drains the command queue and reports the last SlotsPending.
33-func pendingSent(r *multiRig) (pending, sent bool) {
34- for len(r.commands) > 0 {
35- if v, ok := (<-r.commands).(ports.SlotsPending); ok {
36- pending, sent = v.Pending, true
37- }
38- }
39- return pending, sent
40-}
41-
4232 func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
4333 for _, s := range set {
4434 for _, w := range s.Windows {
@@ -62,9 +52,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) {
6252 t.Fatalf("argv %v", req.Argv)
6353 }
6454 token := slotToken(t, req)
65- if pending, sent := pendingSent(r); !sent || !pending {
66- t.Fatal("SlotsPending{true} was not sent")
67- }
6855 r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
6956 r.client <- ports.WindowMapped{ID: 2, Slot: token}
7057 set := receive(t, r.scenes)
@@ -79,9 +66,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) {
7966 if w1, _ := sceneWindow(set, 1); w1.Focused {
8067 t.Fatal("tile kept the focus")
8168 }
82- if pending, sent := pendingSent(r); !sent || pending {
83- t.Fatalf("pending flag not cleared: %v %v", pending, sent)
84- }
8569 // Unstashing tiles it as a column of its own.
8670 set = r.key(t, "s", ports.ModAlt|ports.ModShift)
8771 if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
@@ -112,9 +96,6 @@ func TestToggleEmptyStashOpensTerminal(t *testing.T) {
11296 t.Fatalf("argv %v", req.Argv)
11397 }
11498 token := slotToken(t, req)
115- if pending, sent := pendingSent(r); !sent || !pending {
116- t.Fatal("SlotsPending{true} was not sent")
117- }
11899 r.key(t, "s", ports.ModAlt)
119100 if len(r.spawn) != 0 {
120101 t.Fatal("second terminal while the first is awaited")
@@ -182,9 +163,6 @@ func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
182163 t.Fatalf("argv %v", req.Argv)
183164 }
184165 token := slotToken(t, req)
185- if pending, sent := pendingSent(r.multiRig); !sent || !pending {
186- t.Fatal("SlotsPending{true} was not sent")
187- }
188166 r.four(t, 0, 40, 10)
189167 if len(r.spawn) != 0 {
190168 t.Fatal("second terminal while the first is awaited")
@@ -29,16 +29,6 @@ func slotToken(t *testing.T, req ports.SpawnRequest) string {
29 return req.Env[0][len(ports.SlotEnv)+1:]29 return req.Env[0][len(ports.SlotEnv)+1:]
30 }30 }
31 31
32-// pendingSent drains the command queue and reports the last SlotsPending.
33-func pendingSent(r *multiRig) (pending, sent bool) {
34- for len(r.commands) > 0 {
35- if v, ok := (<-r.commands).(ports.SlotsPending); ok {
36- pending, sent = v.Pending, true
37- }
38- }
39- return pending, sent
40-}
41-
42 func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {32 func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
43 for _, s := range set {33 for _, s := range set {
44 for _, w := range s.Windows {34 for _, w := range s.Windows {
@@ -62,9 +52,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) {
62 t.Fatalf("argv %v", req.Argv)52 t.Fatalf("argv %v", req.Argv)
63 }53 }
64 token := slotToken(t, req)54 token := slotToken(t, req)
65- if pending, sent := pendingSent(r); !sent || !pending {
66- t.Fatal("SlotsPending{true} was not sent")
67- }
68 r.key(t, "Next", ports.ModAlt) // focus moves to workspace 255 r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
69 r.client <- ports.WindowMapped{ID: 2, Slot: token}56 r.client <- ports.WindowMapped{ID: 2, Slot: token}
70 set := receive(t, r.scenes)57 set := receive(t, r.scenes)
@@ -79,9 +66,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) {
79 if w1, _ := sceneWindow(set, 1); w1.Focused {66 if w1, _ := sceneWindow(set, 1); w1.Focused {
80 t.Fatal("tile kept the focus")67 t.Fatal("tile kept the focus")
81 }68 }
82- if pending, sent := pendingSent(r); !sent || pending {
83- t.Fatalf("pending flag not cleared: %v %v", pending, sent)
84- }
85 // Unstashing tiles it as a column of its own.69 // Unstashing tiles it as a column of its own.
86 set = r.key(t, "s", ports.ModAlt|ports.ModShift)70 set = r.key(t, "s", ports.ModAlt|ports.ModShift)
87 if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {71 if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
@@ -112,9 +96,6 @@ func TestToggleEmptyStashOpensTerminal(t *testing.T) {
112 t.Fatalf("argv %v", req.Argv)96 t.Fatalf("argv %v", req.Argv)
113 }97 }
114 token := slotToken(t, req)98 token := slotToken(t, req)
115- if pending, sent := pendingSent(r); !sent || !pending {
116- t.Fatal("SlotsPending{true} was not sent")
117- }
118 r.key(t, "s", ports.ModAlt)99 r.key(t, "s", ports.ModAlt)
119 if len(r.spawn) != 0 {100 if len(r.spawn) != 0 {
120 t.Fatal("second terminal while the first is awaited")101 t.Fatal("second terminal while the first is awaited")
@@ -182,9 +163,6 @@ func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
182 t.Fatalf("argv %v", req.Argv)163 t.Fatalf("argv %v", req.Argv)
183 }164 }
184 token := slotToken(t, req)165 token := slotToken(t, req)
185- if pending, sent := pendingSent(r.multiRig); !sent || !pending {
186- t.Fatal("SlotsPending{true} was not sent")
187- }
188 r.four(t, 0, 40, 10)166 r.four(t, 0, 40, 10)
189 if len(r.spawn) != 0 {167 if len(r.spawn) != 0 {
190 t.Fatal("second terminal while the first is awaited")168 t.Fatal("second terminal while the first is awaited")
M internal/ports/client_commands.go +0 -6
@@ -120,12 +120,6 @@ type SetOutputs struct {
120120
121121 func (SetOutputs) clientCommand() {}
122122
123-// SlotsPending tells wayland whether a slot waits for its window. Only then
124-// does it read the SlotEnv of mapping clients.
125-type SlotsPending struct{ Pending bool }
126-
127-func (SlotsPending) clientCommand() {}
128-
129123 // CloseWindow carries core → wayland close requests.
130124 type CloseWindow struct{ ID WindowID }
131125
@@ -120,12 +120,6 @@ type SetOutputs struct {
120 120
121 func (SetOutputs) clientCommand() {}121 func (SetOutputs) clientCommand() {}
122 122
123-// SlotsPending tells wayland whether a slot waits for its window. Only then
124-// does it read the SlotEnv of mapping clients.
125-type SlotsPending struct{ Pending bool }
126-
127-func (SlotsPending) clientCommand() {}
128-
129 // CloseWindow carries core → wayland close requests.123 // CloseWindow carries core → wayland close requests.
130 type CloseWindow struct{ ID WindowID }124 type CloseWindow struct{ ID WindowID }
131 125

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