Merge 4cf5ef06bed07942226e0d5421cab5396355785d into 353b4e351f6beb5e2901ea31c47e2377239169b2
34 files · +781 -331
M .mockery.yml +5 -0
| @@ -113,6 +113,11 @@ packages: | |||
| 113 | 113 | filename: proc_tree_mock_test.go | |
| 114 | 114 | github.com/bnema/neferwl/internal/adapters/hints: | |
| 115 | 115 | interfaces: | |
| 116 | + | languageRunner: | |
| 117 | + | config: | |
| 118 | + | dir: internal/adapters/hints | |
| 119 | + | pkgname: hints | |
| 120 | + | filename: language_runner_mock_test.go | |
| 116 | 121 | targetSource: | |
| 117 | 122 | config: | |
| 118 | 123 | dir: internal/adapters/hints | |
| @@ -113,6 +113,11 @@ packages: | |||||
| 113 | filename: proc_tree_mock_test.go | 113 | 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/hints | 123 | dir: internal/adapters/hints | ||
M docs/config.md +2 -1
| @@ -335,7 +335,8 @@ small or low-contrast text. | |||
| 335 | 335 | ||
| 336 | 336 | ### 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 | 340 | - Chromium: `--force-renderer-accessibility` | |
| 340 | 341 | - Electron: `ACCESSIBILITY_ENABLED=1` | |
| 341 | 342 | - Qt: `QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1` | |
| @@ -335,7 +335,8 @@ small or low-contrast text. | |||||
| 335 | 335 | ||||
| 336 | ### Requirements | 336 | ### 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 { | |||
| 459 | 459 | call{op: opState, name: n.name, path: n.path}) | |
| 460 | 460 | } | |
| 461 | 461 | rep := do(calls) | |
| 462 | - | var next []ref | |
| 462 | + | room := max(0, maxWalkNodes-seen-len(level)) | |
| 463 | + | next := make([]ref, 0, room) | |
| 463 | 464 | for i, n := range level { | |
| 464 | 465 | ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2] | |
| 465 | 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 | 470 | out = append(out, n) | |
| 470 | 471 | } | |
| 471 | 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 | 477 | seen += len(level) | |
| 476 | - | if room := maxWalkNodes - seen; len(next) > room { | |
| 477 | - | next = next[:max(room, 0)] | |
| 478 | - | } | |
| 479 | 478 | level = next | |
| 480 | 479 | } | |
| 481 | 480 | 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 []ref | 462 | + | 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 = next | 478 | level = next | ||
| 480 | } | 479 | } | ||
| 481 | return out | 480 | return out | ||
M internal/adapters/atspi/source_test.go +40 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "runtime" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -313,3 +314,42 @@ func TestCloseReapsLateTransport(t *testing.T) { | |||
| 313 | 314 | require.Empty(t, covered) | |
| 314 | 315 | 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 | + | } | |
| @@ -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()) // idempotent | 315 | 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 { | |||
| 41 | 41 | var languageWarning sync.Once | |
| 42 | 42 | 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 | 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 | 63 | dir := os.Getenv("TESSDATA_PREFIX") | |
| 46 | 64 | if dir == "" { | |
| 47 | 65 | dir = "/usr/share/tessdata" | |
| @@ -49,7 +67,9 @@ func ocrLanguage(ctx context.Context, binary string) (string, error) { | |||
| 49 | 67 | if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil { | |
| 50 | 68 | 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 | 73 | if err != nil { | |
| 54 | 74 | return "", err | |
| 55 | 75 | } | |
| @@ -41,7 +41,25 @@ type OCRWord struct { | |||||
| 41 | var languageWarning sync.Once | 41 | var languageWarning sync.Once | ||
| 42 | var failureWarning sync.Once | 42 | 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", nil | 68 | 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 "", err | 74 | return "", err | ||
| 55 | } | 75 | } | ||
M internal/adapters/hints/ocr_test.go +34 -0
| @@ -2,6 +2,7 @@ package hints | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "image" | |
| 6 | 7 | "image/draw" | |
| 7 | 8 | "image/png" | |
| @@ -14,6 +15,8 @@ import ( | |||
| 14 | 15 | "testing" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | + | ||
| 17 | 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 | + | } | |
| @@ -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) { | |||
| 258 | 258 | if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 { | |
| 259 | 259 | t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st) | |
| 260 | 260 | } | |
| 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) | |
| 263 | 263 | } | |
| 264 | 264 | release.do() | |
| 265 | 265 | } | |
| @@ -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 { | |||
| 58 | 58 | surface *wayland.Surface | |
| 59 | 59 | devices []*wayland.DataDevice | |
| 60 | 60 | 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. | |
| 63 | 64 | accepted string | |
| 65 | + | acceptor *dndOffer | |
| 64 | 66 | action uint32 | |
| 65 | 67 | state int | |
| 66 | 68 | // 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 | 120 | list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev }) | |
| 119 | 121 | if len(list) == 0 { | |
| 120 | 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 | 126 | // An internal drag has nobody left to tell. | |
| 122 | 127 | if d := s.dnd; d != nil && d.client == c && d.src == nil { | |
| 123 | 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 | 400 | s.dndEnd(d, true, false) | |
| 396 | 401 | return | |
| 397 | 402 | } | |
| @@ -487,6 +492,16 @@ func (s *Server) dndCancelAll() { | |||
| 487 | 492 | func (s *Server) dndOfferGone(o *dndOffer) { | |
| 488 | 493 | t := o.t | |
| 489 | 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 | 505 | if len(t.offers) != 0 { | |
| 491 | 506 | return | |
| 492 | 507 | } | |
| @@ -520,10 +535,14 @@ func (o *dndOffer) update() { | |||
| 520 | 535 | src.res.SendAction(action) | |
| 521 | 536 | } | |
| 522 | 537 | } | |
| 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 | + | } | |
| 527 | 546 | } | |
| 528 | 547 | } | |
| 529 | 548 | } | |
| @@ -534,6 +553,7 @@ func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) { | |||
| 534 | 553 | return | |
| 535 | 554 | } | |
| 536 | 555 | t.accepted = mime | |
| 556 | + | t.acceptor = o | |
| 537 | 557 | t.d.src.res.SendTarget(mime) | |
| 538 | 558 | } | |
| 539 | 559 | ||
| @@ -58,9 +58,11 @@ type dndTarget struct { | |||||
| 58 | surface *wayland.Surface | 58 | surface *wayland.Surface | ||
| 59 | devices []*wayland.DataDevice | 59 | devices []*wayland.DataDevice | ||
| 60 | offers []*dndOffer | 60 | offers []*dndOffer | ||
| 61 | - | // accepted is the MIME type the target accepted; action the action | 61 | + | // 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 string | 64 | accepted string | ||
| 65 | + | acceptor *dndOffer | |||
| 64 | action uint32 | 66 | action uint32 | ||
| 65 | state int | 67 | state int | ||
| 66 | // legacy: no device speaks wl_data_offer v3: the target copies and | 68 | // 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 | return | 401 | 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.t | 493 | 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 | return | 506 | 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 = action | 539 | + | 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 | return | 553 | return | ||
| 535 | } | 554 | } | ||
| 536 | t.accepted = mime | 555 | 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 { | |||
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 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 | 202 | 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) | |
| 208 | 208 | } | |
| 209 | 209 | select { | |
| 210 | 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 | 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 | 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 | 378 | for { | |
| 373 | 379 | select { | |
| 374 | 380 | case ev := <-events: | |
| 381 | + | if v, ok := ev.(ports.WindowResized); ok && v.ID == marker.ID { | |
| 382 | + | return | |
| 383 | + | } | |
| 375 | 384 | if _, ok := ev.(T); ok { | |
| 376 | 385 | t.Fatalf("unexpected %T: %+v", ev, ev) | |
| 377 | 386 | } | |
| 378 | - | default: | |
| 379 | - | return | |
| 387 | + | case <-deadline: | |
| 388 | + | t.Fatal("no event barrier") | |
| 380 | 389 | } | |
| 381 | 390 | } | |
| 382 | 391 | } | |
| @@ -483,10 +492,10 @@ func TestDataDragBetweenClients(t *testing.T) { | |||
| 483 | 492 | requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish) | |
| 484 | 493 | p.a.expect(t, "finished") | |
| 485 | 494 | 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) | |
| 488 | 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 | 501 | // The action follows wlroots: the preferred one if both allow it, else the | |
| @@ -534,7 +543,7 @@ func TestDataDragRefusedDrop(t *testing.T) { | |||
| 534 | 543 | p.start(t, 0) | |
| 535 | 544 | p.commands <- ports.DataDragDrop{} | |
| 536 | 545 | p.a.expect(t, "cancelled") | |
| 537 | - | p.b.none(t) | |
| 546 | + | p.b.none(t, p.commands) | |
| 538 | 547 | }) | |
| 539 | 548 | t.Run("not accepted", func(t *testing.T) { | |
| 540 | 549 | p := newDndPair(t) | |
| @@ -543,7 +552,7 @@ func TestDataDragRefusedDrop(t *testing.T) { | |||
| 543 | 552 | p.commands <- ports.DataDragDrop{} | |
| 544 | 553 | p.b.expect(t, "leave") | |
| 545 | 554 | p.a.expect(t, "cancelled") | |
| 546 | - | p.b.none(t) | |
| 555 | + | p.b.none(t, p.commands) | |
| 547 | 556 | }) | |
| 548 | 557 | t.Run("escape", func(t *testing.T) { | |
| 549 | 558 | p := newDndPair(t) | |
| @@ -552,7 +561,7 @@ func TestDataDragRefusedDrop(t *testing.T) { | |||
| 552 | 561 | p.commands <- ports.DataDragCancel{} | |
| 553 | 562 | p.b.expect(t, "leave") | |
| 554 | 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 | 596 | dndNextEvent[ports.DataDragEnded](t, p.events) | |
| 588 | 597 | p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9} | |
| 589 | 598 | p.commands <- ports.DataDragDrop{} | |
| 590 | - | p.b.none(t) | |
| 599 | + | p.b.none(t, p.commands) | |
| 591 | 600 | } | |
| 592 | 601 | ||
| 593 | 602 | // A drag whose origin client dies ends the same way. | |
| @@ -608,7 +617,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 608 | 617 | serial := p.a.press(t, p.commands) | |
| 609 | 618 | p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1) | |
| 610 | 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 | 622 | t.Run("other client's press", func(t *testing.T) { | |
| 614 | 623 | p := newDndPair(t) | |
| @@ -616,7 +625,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 616 | 625 | serial := p.b.press(t, p.commands) | |
| 617 | 626 | p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial) | |
| 618 | 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 | 630 | t.Run("key serial", func(t *testing.T) { | |
| 622 | 631 | p := newDndPair(t) | |
| @@ -625,7 +634,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 625 | 634 | serial := p.a.key(t, p.commands) | |
| 626 | 635 | p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial) | |
| 627 | 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 | 639 | t.Run("serial after the release", func(t *testing.T) { | |
| 631 | 640 | p := newDndPair(t) | |
| @@ -633,7 +642,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 633 | 642 | p.a.release(t, p.commands) | |
| 634 | 643 | p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial) | |
| 635 | 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 | 647 | t.Run("other surface of the client", func(t *testing.T) { | |
| 639 | 648 | p := newDndPair(t) | |
| @@ -641,7 +650,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 641 | 650 | other := p.a.surfaceNew(t) | |
| 642 | 651 | p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial) | |
| 643 | 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 | 655 | t.Run("press of the first button", func(t *testing.T) { | |
| 647 | 656 | // A second button does not make a new grab: only the first serial. | |
| @@ -675,7 +684,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 675 | 684 | p.a.key(t, p.commands) // the release is applied once the key is seen | |
| 676 | 685 | p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial) | |
| 677 | 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 | 689 | t.Run("used source", func(t *testing.T) { | |
| 681 | 690 | p := newDndPair(t) | |
| @@ -692,7 +701,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 692 | 701 | other := p.a.source(t, actCopy, "text/plain") | |
| 693 | 702 | p.a.startDrag(t, other, 0, p.a.press(t, p.commands)) | |
| 694 | 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 | 706 | t.Run("icon with another role", func(t *testing.T) { | |
| 698 | 707 | p := newDndPair(t) | |
| @@ -701,7 +710,7 @@ func TestDataDragInvalidStart(t *testing.T) { | |||
| 701 | 710 | // The origin toplevel is a role already. | |
| 702 | 711 | requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial) | |
| 703 | 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 | 721 | p.a.startDrag(t, 0, 0, p.a.press(t, p.commands)) | |
| 713 | 722 | dndNextEvent[ports.DataDragStarted](t, p.events) | |
| 714 | 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 | 725 | p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5} | |
| 717 | 726 | if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 { | |
| 718 | 727 | t.Fatalf("enter %+v", ev) | |
| @@ -837,7 +846,7 @@ func TestDataDragIcon(t *testing.T) { | |||
| 837 | 846 | t.Fatalf("icon %+v", got) | |
| 838 | 847 | } | |
| 839 | 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 | 851 | p.commands <- ports.DataDragCancel{} | |
| 843 | 852 | p.a.expect(t, "cancelled") | |
| @@ -1064,8 +1073,8 @@ func TestDataDragAsk(t *testing.T) { | |||
| 1064 | 1073 | requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain") | |
| 1065 | 1074 | requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove) | |
| 1066 | 1075 | 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 | |
| 1069 | 1078 | requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish) | |
| 1070 | 1079 | if ev := p.a.expect(t, "action"); ev.n != actMove { | |
| 1071 | 1080 | t.Fatalf("action %+v", ev) | |
| @@ -1104,8 +1113,7 @@ func TestDataDragLegacyTarget(t *testing.T) { | |||
| 1104 | 1113 | p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1} | |
| 1105 | 1114 | p.b.until(t, "enter") | |
| 1106 | 1115 | 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. | |
| 1109 | 1117 | return p, id | |
| 1110 | 1118 | } | |
| 1111 | 1119 | t.Run("move only", func(t *testing.T) { | |
| @@ -1152,3 +1160,101 @@ func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) { | |||
| 1152 | 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 | + | } | |
| @@ -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 | - | return | 388 | + | 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 the | 501 | // 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 seen | 684 | 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 drop | 1076 | + | p.b.none(t, p.commands) // no offer.action after the drop |
| 1068 | - | p.a.none(t) // the final action waits for the finish | 1077 | + | 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, id | 1117 | 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) { | |||
| 242 | 242 | // client received what followed. | |
| 243 | 243 | func (h *captureHarness) settle(t *testing.T, cc *cclient) { | |
| 244 | 244 | 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) | |
| 255 | 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 | 249 | func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd } | |
| 279 | 250 | ||
| 280 | 251 | // 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 applies | 245 | + | 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 { | |||
| 129 | 129 | wlturbo.BaseProxy | |
| 130 | 130 | opcodes []uint16 | |
| 131 | 131 | 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 | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 136 | 137 | func (p *eventsProxy) Dispatch(e *wlturbo.Event) { | |
| 137 | 138 | p.opcodes = append(p.opcodes, e.Opcode) | |
| 139 | + | if e.Opcode == uint16(wayland.KeyboardEventRepeatInfo) && p.repeat != nil { | |
| 140 | + | p.repeat <- struct{}{} | |
| 141 | + | } | |
| 138 | 142 | if e.Opcode == uint16(wayland.KeyboardEventModifiers) { | |
| 139 | 143 | e.Uint32() // serial | |
| 140 | 144 | p.depress = append(p.depress, e.Uint32()) | |
| @@ -267,30 +271,11 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) { | |||
| 267 | 271 | set := make(chan struct{}) | |
| 268 | 272 | s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) }) | |
| 269 | 273 | <-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 | 274 | wait := func() { | |
| 275 | 275 | 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) | |
| 293 | 277 | } | |
| 278 | + | ||
| 294 | 279 | proxy.depress, proxy.locked = nil, nil | |
| 295 | 280 | commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl} | |
| 296 | 281 | wait() | |
| @@ -341,3 +326,54 @@ func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) { | |||
| 341 | 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 | + | } | |
| @@ -129,12 +129,16 @@ type eventsProxy struct { | |||||
| 129 | wlturbo.BaseProxy | 129 | wlturbo.BaseProxy | ||
| 130 | opcodes []uint16 | 130 | opcodes []uint16 | ||
| 131 | keymaps []string | 131 | keymaps []string | ||
| 132 | - | locked []uint32 // locked modifiers of each modifiers event | 132 | + | locked []uint32 // locked modifiers of each modifiers event |
| 133 | - | depress []uint32 // depressed modifiers of each modifiers event | 133 | + | 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() // serial | 143 | 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 | <-set | 273 | <-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 = !pending | 276 | + | 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, nil | 279 | 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 { | |||
| 48 | 48 | func (r *postRing[T]) takeStats() ringStats { | |
| 49 | 49 | r.mu.Lock() | |
| 50 | 50 | st := r.stats | |
| 51 | + | st.maxLen = max(st.maxLen, len(r.ring)) | |
| 51 | 52 | r.stats = ringStats{} | |
| 52 | 53 | r.mu.Unlock() | |
| 53 | 54 | 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.stats | 50 | 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 st | 54 | return st | ||
M internal/adapters/wayland/postring_test.go +34 -0
| @@ -207,3 +207,37 @@ func waitPushWaiting(t *testing.T) { | |||
| 207 | 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 | + | } | |
| @@ -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 { | |||
| 162 | 162 | securityLive atomic.Int32 | |
| 163 | 163 | display *server.Display | |
| 164 | 164 | env procEnv | |
| 165 | - | slotsPending bool // core waits for a slot window | |
| 166 | 165 | name string | |
| 167 | 166 | cleanup func() | |
| 168 | 167 | log zerowrap.Logger | |
| @@ -809,8 +808,6 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 809 | 808 | s.captureState(c) | |
| 810 | 809 | case ports.UserActivity: | |
| 811 | 810 | s.userActivity() | |
| 812 | - | case ports.SlotsPending: | |
| 813 | - | s.slotsPending = c.Pending | |
| 814 | 811 | case ports.ShortcutsInhibitState: | |
| 815 | 812 | s.setShortcutsInhibit(c) | |
| 816 | 813 | case ports.CloseWindow: | |
| @@ -162,7 +162,6 @@ type Server struct { | |||||
| 162 | securityLive atomic.Int32 | 162 | securityLive atomic.Int32 | ||
| 163 | display *server.Display | 163 | display *server.Display | ||
| 164 | env procEnv | 164 | env procEnv | ||
| 165 | - | slotsPending bool // core waits for a slot window | |||
| 166 | name string | 165 | name string | ||
| 167 | cleanup func() | 166 | cleanup func() | ||
| 168 | log zerowrap.Logger | 167 | 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) { | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | // 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. | |
| 42 | 44 | func (s *Server) slotToken(c server.Client) string { | |
| 43 | - | if !s.slotsPending { | |
| 44 | - | return "" | |
| 45 | - | } | |
| 46 | 45 | v, _ := s.env.Lookup(c.PID(), ports.SlotEnv) | |
| 47 | 46 | return v | |
| 48 | 47 | } | |
| @@ -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. It | 39 | // 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 | 40 | + | // 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 v | 46 | return v | ||
| 48 | } | 47 | } | ||
M internal/adapters/wayland/slot_test.go +3 -10
| @@ -14,18 +14,11 @@ func TestWindowMappedCarriesSlot(t *testing.T) { | |||
| 14 | 14 | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once() | |
| 15 | 15 | s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env }) | |
| 16 | 16 | 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() { | |
| 19 | 19 | t.Fatalf("slot %q pid %d", w.Slot, w.PID) | |
| 20 | 20 | } | |
| 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 | + | ||
| 29 | 22 | } | |
| 30 | 23 | ||
| 31 | 24 | 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 gate | 21 | + | |
| 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) { | |||
| 239 | 239 | } else if buffer && !w.mapped && x.acked { | |
| 240 | 240 | w.mapped = true | |
| 241 | 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 | 248 | // Its size comes with the commit, in WindowResized (afterCommit). | |
| 244 | 249 | w.floating = w.floats() | |
| 245 | 250 | 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 = true | 240 | 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.WindowID | 250 | var parent ports.WindowID | ||
M internal/core/core.go +13 -53
| @@ -204,7 +204,6 @@ type sentCache struct { | |||
| 204 | 204 | outputs ports.SetOutputs | |
| 205 | 205 | state ports.State | |
| 206 | 206 | workspaces ports.Workspaces | |
| 207 | - | pending bool // spawn placement pending | |
| 208 | 207 | } | |
| 209 | 208 | ||
| 210 | 209 | func keyName(s string) string { | |
| @@ -364,23 +363,6 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 364 | 363 | 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 | 366 | // spawnSlots starts the slots queued by config. With shown set (the user | |
| 385 | 367 | // just switched to another workspace), it also refills the empty slots of | |
| 386 | 368 | // the workspace now on screen. | |
| @@ -406,13 +388,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||
| 406 | 388 | } | |
| 407 | 389 | started[key] = true | |
| 408 | 390 | 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() { | |
| 412 | 392 | c.placement.dropSlot(key) | |
| 413 | 393 | st.stale = false | |
| 414 | 394 | c.toSpawn = append(c.toSpawn, keys[i:]...) | |
| 415 | - | return pendingErr | |
| 395 | + | return err | |
| 416 | 396 | } | |
| 417 | 397 | if !c.trySpawn(ctx, req) { | |
| 418 | 398 | c.placement.dropSlot(key) | |
| @@ -473,26 +453,6 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||
| 473 | 453 | 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 | 456 | // queue pushes v to the outbox and returns its sequence number (0: not | |
| 497 | 457 | // queued). | |
| 498 | 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 | 649 | if err := c.spawnEmpty(ctx); err != nil { | |
| 690 | 650 | return err | |
| 691 | 651 | } | |
| 692 | - | if err := c.publishPending(ctx); err != nil { | |
| 693 | - | return err | |
| 694 | - | } | |
| 695 | 652 | focus := c.keyboardFocus() | |
| 696 | 653 | c.advanceFlash(only) | |
| 697 | 654 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| @@ -1120,7 +1077,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1120 | 1077 | c.startup[i] = slices.Clone(argv) | |
| 1121 | 1078 | } | |
| 1122 | 1079 | c.spawnStartup(ctx) | |
| 1123 | - | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | |
| 1080 | + | if c.spawnSlots(ctx, false) != nil { | |
| 1124 | 1081 | return nil | |
| 1125 | 1082 | } | |
| 1126 | 1083 | c.publishState() | |
| @@ -1153,8 +1110,13 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1153 | 1110 | if stop { | |
| 1154 | 1111 | return nil | |
| 1155 | 1112 | } | |
| 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 | + | } | |
| 1158 | 1120 | } | |
| 1159 | 1121 | case f, ok := <-c.ch.Frames: | |
| 1160 | 1122 | if c.securityCheckpoint(ctx) != nil { | |
| @@ -1350,13 +1312,11 @@ func (c *Core) spawnEmpty(ctx context.Context) error { | |||
| 1350 | 1312 | } | |
| 1351 | 1313 | ||
| 1352 | 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 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. | |
| 1357 | 1317 | func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) { | |
| 1358 | - | err = c.publishPending(ctx) | |
| 1359 | 1318 | for i, req := range reqs { | |
| 1319 | + | err = ctx.Err() | |
| 1360 | 1320 | if err != nil || !c.trySpawn(ctx, req) { | |
| 1361 | 1321 | for _, suppressed := range reqs[i:] { | |
| 1362 | 1322 | c.cancelClaim(suppressed) | |
| @@ -204,7 +204,6 @@ type sentCache struct { | |||||
| 204 | outputs ports.SetOutputs | 204 | outputs ports.SetOutputs | ||
| 205 | state ports.State | 205 | state ports.State | ||
| 206 | workspaces ports.Workspaces | 206 | 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 nil | 363 | 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 user | 366 | // spawnSlots starts the slots queued by config. With shown set (the user | ||
| 385 | // just switched to another workspace), it also refills the empty slots of | 367 | // 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] = true | 389 | 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 = false | 393 | st.stale = false | ||
| 414 | c.toSpawn = append(c.toSpawn, keys[i:]...) | 394 | c.toSpawn = append(c.toSpawn, keys[i:]...) | ||
| 415 | - | return pendingErr | 395 | + | 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 err | 453 | 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: not | 456 | // 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 err | 650 | 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 nil | 1081 | 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 nil | 1111 | return nil | ||
| 1155 | } | 1112 | } | ||
| 1156 | - | if publish && c.publish(ctx) != nil { | 1113 | + | if publish { |
| 1157 | - | return nil | 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 | + | } | |||
| 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 the | 1315 | + | // 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 is | 1316 | + | // 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 { | |||
| 94 | 94 | 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 | 112 | // dataDragRetarget re-routes a still cursor: the target is told only when | |
| 98 | 113 | // it or the point in it changed. | |
| 99 | 114 | 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 nil | 94 | 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 when | 112 | // 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) { | |||
| 575 | 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 | + | } | |
| @@ -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 { | |||
| 87 | 87 | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |
| 88 | 88 | return false | |
| 89 | 89 | } | |
| 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 | |
| 114 | 93 | } | |
| 115 | 94 | if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown { | |
| 116 | 95 | return false | |
| @@ -139,7 +118,7 @@ func (c *Core) advanceFlash(only *screen) { | |||
| 139 | 118 | return | |
| 140 | 119 | } | |
| 141 | 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 | 122 | c.stopFlash() | |
| 144 | 123 | return | |
| 145 | 124 | } | |
| @@ -156,27 +135,50 @@ func (c *Core) advanceFlash(only *screen) { | |||
| 156 | 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 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) { | |
| 162 | 142 | if sc.mon.ov.open { | |
| 163 | - | return false | |
| 143 | + | return Rect{}, false | |
| 164 | 144 | } | |
| 165 | 145 | if target.level == flashMonitor { | |
| 166 | - | return true | |
| 146 | + | o := sc.mon.Output() | |
| 147 | + | return Rect{W: o.W, H: o.H}, true | |
| 167 | 148 | } | |
| 168 | 149 | owner, _ := c.screenOf(target.window) | |
| 169 | 150 | if owner != sc || c.windows.lookup(target.window).client.ID == 0 { | |
| 170 | - | return false | |
| 151 | + | return Rect{}, false | |
| 171 | 152 | } | |
| 172 | 153 | view := sc.mon.viewport() | |
| 173 | 154 | c.flash.layout = sc.mon.layoutInto(c.flash.layout) | |
| 155 | + | var rect Rect | |
| 156 | + | found := false | |
| 174 | 157 | for _, p := range c.flash.layout { | |
| 175 | 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 false | |
| 181 | + | return rect, true | |
| 180 | 182 | } | |
| 181 | 183 | ||
| 182 | 184 | 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 false | 88 | return false | ||
| 89 | } | 89 | } | ||
| 90 | - | layout := sc.mon.Layout() | 90 | + | rect, ok := c.flashRect(target, sc) |
| 91 | - | var rect Rect | 91 | + | 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 false | 95 | return false | ||
| @@ -139,7 +118,7 @@ func (c *Core) advanceFlash(only *screen) { | |||||
| 139 | return | 118 | 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 | return | 123 | 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 for | 138 | + | // 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 false | 143 | + | return Rect{}, false |
| 164 | } | 144 | } | ||
| 165 | if target.level == flashMonitor { | 145 | if target.level == flashMonitor { | ||
| 166 | - | return true | 146 | + | 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 false | 151 | + | 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 false | 181 | + | 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) { | |||
| 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 | + | } | |
| @@ -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 ( | |||
| 33 | 33 | type hintTarget struct { | |
| 34 | 34 | x, y float64 | |
| 35 | 35 | window WindowID | |
| 36 | + | source hintSourceGeom // source window and its request-time client geometry | |
| 36 | 37 | label string | |
| 37 | 38 | rect ports.Rect | |
| 38 | 39 | text bool // found by OCR | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | type hintSourceGeom struct { | |
| 43 | + | id WindowID | |
| 44 | + | rect ports.Rect | |
| 45 | + | } | |
| 46 | + | ||
| 41 | 47 | type hintState struct { | |
| 42 | 48 | phase hintPhase | |
| 43 | 49 | seq uint64 // last request sequence number | |
| @@ -161,10 +167,11 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 161 | 167 | // A layer surface, or a window of another output. | |
| 162 | 168 | continue | |
| 163 | 169 | } | |
| 164 | - | if !c.hintOwned(sc, t, root) { | |
| 170 | + | source, ok := c.hintSource(sc, t, root) | |
| 171 | + | if !ok { | |
| 165 | 172 | 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 | 176 | if len(kept) == 0 { | |
| 170 | 177 | c.hintCancel() | |
| @@ -188,22 +195,24 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 188 | 195 | return true | |
| 189 | 196 | } | |
| 190 | 197 | ||
| 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 | |
| 198 | 203 | } | |
| 199 | 204 | 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 | |
| 202 | 207 | } | |
| 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 | |
| 207 | 216 | } | |
| 208 | 217 | } | |
| 209 | 218 | return false | |
| @@ -340,7 +349,8 @@ func (c *Core) hintClickable(t hintTarget) bool { | |||
| 340 | 349 | return false | |
| 341 | 350 | } | |
| 342 | 351 | 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) | |
| 344 | 354 | } | |
| 345 | 355 | ||
| 346 | 356 | // 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 float64 | 34 | x, y float64 | ||
| 35 | window WindowID | 35 | window WindowID | ||
| 36 | + | source hintSourceGeom // source window and its request-time client geometry | |||
| 36 | label string | 37 | label string | ||
| 37 | rect ports.Rect | 38 | rect ports.Rect | ||
| 38 | text bool // found by OCR | 39 | 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 hintPhase | 48 | phase hintPhase | ||
| 43 | seq uint64 // last request sequence number | 49 | 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 | continue | 168 | continue | ||
| 163 | } | 169 | } | ||
| 164 | - | if !c.hintOwned(sc, t, root) { | 170 | + | source, ok := c.hintSource(sc, t, root) |
| 171 | + | if !ok { | |||
| 165 | continue | 172 | 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 true | 195 | return true | ||
| 189 | } | 196 | } | ||
| 190 | 197 | ||||
| 191 | - | // hintOwned reports whether the window under a target is the one it was | 198 | + | // hintSource resolves the request-time source window only while its client |
| 192 | - | // scanned on. An accessibility target names its window; an OCR target only | 199 | + | // 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 has | 200 | + | 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 | - | continue | 206 | + | 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.Rect | 210 | + | } |
| 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 false | 218 | return false | ||
| @@ -340,7 +349,8 @@ func (c *Core) hintClickable(t hintTarget) bool { | |||||
| 340 | return false | 349 | return false | ||
| 341 | } | 350 | } | ||
| 342 | id, _, _ := c.hit(t.x, t.y) | 351 | 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) | |||
| 344 | } | 354 | } | ||
| 345 | 355 | ||||
| 346 | // hintLabels are the labels to draw on sc: those still matching what was | 356 | // 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) { | |||
| 832 | 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 | + | } | |
| @@ -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 { | |||
| 51 | 51 | // MaxBacklog is the longest queue seen. | |
| 52 | 52 | MaxBacklog uint64 | |
| 53 | 53 | // 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. | |
| 56 | 55 | MaxQueueWait, MaxFlushWait time.Duration | |
| 57 | 56 | } | |
| 58 | 57 | ||
| @@ -61,6 +60,7 @@ func (o *outbox) takeStats() CommandStats { | |||
| 61 | 60 | o.mu.Lock() | |
| 62 | 61 | defer o.mu.Unlock() | |
| 63 | 62 | s := o.stats | |
| 63 | + | s.MaxBacklog = max(s.MaxBacklog, o.pushed-o.sent) | |
| 64 | 64 | o.stats = CommandStats{} | |
| 65 | 65 | 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 | 173 | // 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 | |
| 176 | 175 | // without Run), it delivers the queue itself, in order. | |
| 177 | 176 | func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error { | |
| 178 | 177 | 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 uint64 | 52 | MaxBacklog uint64 | ||
| 53 | // MaxQueueWait is the longest time a queued command took to reach the | 53 | // 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 | 54 | + | // channel; MaxFlushWait the longest time core waited for outbox room. |
| 55 | - | // SlotsPending, or a full outbox). | |||
| 56 | MaxQueueWait, MaxFlushWait time.Duration | 55 | 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.stats | 62 | s := o.stats | ||
| 63 | + | s.MaxBacklog = max(s.MaxBacklog, o.pushed-o.sent) | |||
| 64 | o.stats = CommandStats{} | 64 | o.stats = CommandStats{} | ||
| 65 | return s | 65 | 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 the | 173 | // 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.Time | 177 | var start time.Time | ||
M internal/core/outbox_test.go +20 -0
| @@ -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 | + | } | |
| @@ -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) { | |||
| 647 | 647 | case <-time.After(time.Second): | |
| 648 | 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 | 650 | if req := receive(t, spawn); len(req.Argv) == 0 { | |
| 654 | 651 | t.Fatal(req) | |
| 655 | 652 | } | |
| @@ -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) { | |||
| 77 | 77 | } | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) { | |
| 80 | + | func TestEpochChangeSuppressesSpawnAndDefersSlots(t *testing.T) { | |
| 81 | 81 | c, _, _ := hiddenCaptureCommands(t) | |
| 82 | 82 | key := slotKey{workspace: "dev", index: 1} | |
| 83 | 83 | c.slots[key] = &slotState{argv: []string{"terminal"}} | |
| @@ -100,10 +100,6 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) { | |||
| 100 | 100 | spawn := make(chan ports.SpawnRequest, 2) | |
| 101 | 101 | c.ch.Spawn = spawn | |
| 102 | 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 | 103 | if err != nil { | |
| 108 | 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 | 122 | c, _, _ := hiddenCaptureCommands(t) | |
| 127 | 123 | c.cur().mon.RemoveWindow(1) | |
| 128 | 124 | c.cur().mon.RemoveWindow(2) | |
| @@ -147,7 +143,6 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T | |||
| 147 | 143 | spawn := make(chan ports.SpawnRequest, 1) | |
| 148 | 144 | c.ch.Spawn = spawn | |
| 149 | 145 | err := c.spawnEmpty(context.Background()) | |
| 150 | - | reviewReceive(t, commands) | |
| 151 | 146 | if err != nil { | |
| 152 | 147 | t.Fatal(err) | |
| 153 | 148 | } | |
| @@ -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 = spawn | 101 | 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 = spawn | 144 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "strings" | |
| 6 | 5 | "testing" | |
| 7 | 6 | "time" | |
| @@ -32,15 +31,7 @@ func TestSlotClaimWithoutWorkspace(t *testing.T) { | |||
| 32 | 31 | if s, _ := c.screenOf(21); s == nil { | |
| 33 | 32 | t.Fatal("window did not map normally") | |
| 34 | 33 | } | |
| 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 | + | ||
| 44 | 35 | } | |
| 45 | 36 | ||
| 46 | 37 | func TestExpiredTerminalTokenMapsNormally(t *testing.T) { | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package core | 1 | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | - | "testing/synctest" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| @@ -40,46 +39,23 @@ func claimToken(req ports.SpawnRequest) string { | |||
| 40 | 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 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 | + | } | |
| 79 | 56 | } | |
| 80 | 57 | ||
| 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. | |
| 83 | 59 | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |
| 84 | 60 | now := claimBase | |
| 85 | 61 | commands := make(chan ports.ClientCommand, 8) | |
| @@ -90,15 +66,6 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |||
| 90 | 66 | if c.placement.anyPending() { | |
| 91 | 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 | 71 | // 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 | 79 | wantErr error | |
| 113 | 80 | }{ | |
| 114 | 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 | 84 | t.Run(tc.name, func(t *testing.T) { | |
| 118 | 85 | 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 | |
| 120 | 87 | spawn := make(chan ports.SpawnRequest, tc.queue) | |
| 121 | 88 | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | |
| 122 | 89 | ctx, cancel := context.WithCancel(context.Background()) | |
| 123 | 90 | t.Cleanup(cancel) | |
| 124 | 91 | if tc.cancelled { | |
| 125 | 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 | 94 | err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false) | |
| 136 | 95 | 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 the | 42 | + | // A stalled command transport must not delay a placement spawn. |
| 44 | - | // command channel before the spawn can reach the launcher, also when the | 43 | + | func TestSpawnStashIndependentOfCommandDrain(t *testing.T) { |
| 45 | - | // display reads nothing: in a synctest bubble the sender is waited on until | 44 | + | 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 := claimBase | 48 | + | 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 the | 58 | + | // 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 := claimBase | 60 | 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 their | 71 | // 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 error | 79 | 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 := claimBase | 85 | 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 |
| 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 { | |||
| 29 | 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 | 32 | func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |
| 43 | 33 | for _, s := range set { | |
| 44 | 34 | for _, w := range s.Windows { | |
| @@ -62,9 +52,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) { | |||
| 62 | 52 | t.Fatalf("argv %v", req.Argv) | |
| 63 | 53 | } | |
| 64 | 54 | token := slotToken(t, req) | |
| 65 | - | if pending, sent := pendingSent(r); !sent || !pending { | |
| 66 | - | t.Fatal("SlotsPending{true} was not sent") | |
| 67 | - | } | |
| 68 | 55 | r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2 | |
| 69 | 56 | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |
| 70 | 57 | set := receive(t, r.scenes) | |
| @@ -79,9 +66,6 @@ func TestStashBindOpensWindowInStash(t *testing.T) { | |||
| 79 | 66 | if w1, _ := sceneWindow(set, 1); w1.Focused { | |
| 80 | 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 | 69 | // Unstashing tiles it as a column of its own. | |
| 86 | 70 | set = r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 87 | 71 | if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused { | |
| @@ -112,9 +96,6 @@ func TestToggleEmptyStashOpensTerminal(t *testing.T) { | |||
| 112 | 96 | t.Fatalf("argv %v", req.Argv) | |
| 113 | 97 | } | |
| 114 | 98 | token := slotToken(t, req) | |
| 115 | - | if pending, sent := pendingSent(r); !sent || !pending { | |
| 116 | - | t.Fatal("SlotsPending{true} was not sent") | |
| 117 | - | } | |
| 118 | 99 | r.key(t, "s", ports.ModAlt) | |
| 119 | 100 | if len(r.spawn) != 0 { | |
| 120 | 101 | t.Fatal("second terminal while the first is awaited") | |
| @@ -182,9 +163,6 @@ func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) { | |||
| 182 | 163 | t.Fatalf("argv %v", req.Argv) | |
| 183 | 164 | } | |
| 184 | 165 | token := slotToken(t, req) | |
| 185 | - | if pending, sent := pendingSent(r.multiRig); !sent || !pending { | |
| 186 | - | t.Fatal("SlotsPending{true} was not sent") | |
| 187 | - | } | |
| 188 | 166 | r.four(t, 0, 40, 10) | |
| 189 | 167 | if len(r.spawn) != 0 { | |
| 190 | 168 | 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 2 | 55 | 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 { | |||
| 120 | 120 | ||
| 121 | 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 | 123 | // CloseWindow carries core → wayland close requests. | |
| 130 | 124 | type CloseWindow struct{ ID WindowID } | |
| 131 | 125 | ||
| @@ -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 | ||||