detached 73f9772

fix(atspi): own the source from the hints worker and bound connect attempts

commit
73f97723d0a734209d0b820a8b7e8eebd1eab362
author
brice · · 2026-10-19 15:57 UTC
parent
88f7989
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +232 -42
M internal/adapters/atspi/source.go +90 -31
@@ -32,26 +32,39 @@ type connector interface {
3232 Connect(ctx context.Context) (transport, error)
3333 }
3434
35+// stallWarnAfter is how long an attempt may be in flight before it is
36+// reported as a stalled accessibility bus.
37+const stallWarnAfter = 5 * time.Second
38+
3539 // dialResult is the outcome of a connection attempt.
3640 type dialResult struct {
3741 tr transport
3842 err error
43+ at time.Time // when the attempt finished
3944 }
4045
4146 // Source reads hint targets from the accessibility tree. It owns one
42-// connection to the accessibility bus and is used by one goroutine at a
43-// time (the hints worker). The connection is made on the first Targets call
47+// connection to the accessibility bus. It is NOT safe for concurrent use:
48+// it is owned by the hints worker goroutine, which calls Targets and,
49+// when it returns, Close. The connection is made on the first Targets call
4450 // that needs it, never at startup, and made again after a failure, at most
4551 // once every retryEvery; meanwhile Targets finds nothing and OCR takes over.
52+//
53+// At most one connection attempt (one goroutine and one socket) exists at
54+// any time: an attempt that outlives the wait of ensure stays in flight, is
55+// polled by the later Targets calls and is never doubled.
4656 type Source struct {
4757 tr transport
4858 dial connector
49- // attempt is the outcome of a connection attempt that may still run: a
50- // silent service is abandoned, not waited for, and its late result is
51- // closed.
52- attempt chan dialResult
53- attemptDue time.Time
54- retryAt time.Time
59+ // attempt delivers the outcome of the attempt in flight (buffered, so
60+ // the attempt goroutine never blocks); nil when none runs.
61+ attempt chan dialResult
62+ attemptStarted time.Time
63+ warnedStall bool
64+ retryAt time.Time
65+ closed bool
66+ // wait is how long ensure waits for a new attempt; connectBudget if 0.
67+ wait time.Duration
5568 }
5669
5770 // New returns a Source. It does not touch the bus: the connection is made by
@@ -64,6 +77,11 @@ type busConnector struct{}
6477
6578 func (busConnector) Connect(ctx context.Context) (transport, error) {
6679 log := logging.For(ctx, "atspi")
80+ // zerobus has no deadline or context API: SessionBus and Dial run the
81+ // socket dial, SASL auth and Hello without a bound, and the watchdog
82+ // below cannot exist before the connection does. A stall there is
83+ // bounded by the single-in-flight rule of Source.ensure, not by a
84+ // deadline. Follow-up: a deadline-capable zerobus API.
6785 sess, err := zerobus.SessionBus()
6886 if err != nil {
6987 return nil, fmt.Errorf("atspi: session bus: %w", err)
@@ -111,8 +129,13 @@ func (busConnector) Connect(ctx context.Context) (transport, error) {
111129 }
112130
113131 // ensure returns the connection, making one if none exists and the last
114-// attempt is old enough. It waits at most connectBudget for an attempt.
132+// attempt is old enough. It waits at most the budget for a new attempt; an
133+// attempt that does not answer in time stays in flight (abandoned, never
134+// doubled) and is polled without blocking by the next calls.
115135 func (s *Source) ensure(ctx context.Context) transport {
136+ if s.closed {
137+ return nil
138+ }
116139 if s.tr != nil {
117140 return s.tr
118141 }
@@ -120,47 +143,82 @@ func (s *Source) ensure(ctx context.Context) transport {
120143 return nil
121144 }
122145 now := time.Now()
123- if s.attempt == nil {
124- if now.Before(s.retryAt) {
125- return nil
146+ if s.attempt != nil {
147+ select {
148+ case r := <-s.attempt:
149+ s.finish(ctx, r)
150+ default:
151+ if !s.warnedStall && now.Sub(s.attemptStarted) > stallWarnAfter {
152+ s.warnedStall = true
153+ log := logging.For(ctx, "atspi")
154+ log.Warn().Dur("for", now.Sub(s.attemptStarted)).Msg("accessibility bus not answering")
155+ }
126156 }
127- s.retryAt = now.Add(retryEvery)
128- s.attemptDue = now.Add(connectBudget)
129- ch := make(chan dialResult, 1)
130- s.attempt = ch
131- go func() {
132- tr, err := s.dial.Connect(ctx)
133- ch <- dialResult{tr, err}
134- }()
157+ return s.tr
158+ }
159+ if now.Before(s.retryAt) {
160+ return nil
135161 }
136- timer := time.NewTimer(time.Until(s.attemptDue))
162+ ch := make(chan dialResult, 1)
163+ s.attempt = ch
164+ s.attemptStarted = now
165+ s.warnedStall = false
166+ dial := s.dial
167+ go func() {
168+ tr, err := dial.Connect(ctx)
169+ ch <- dialResult{tr: tr, err: err, at: time.Now()}
170+ }()
171+ wait := s.wait
172+ if wait <= 0 {
173+ wait = connectBudget
174+ }
175+ timer := time.NewTimer(wait)
137176 defer timer.Stop()
138177 select {
139- case r := <-s.attempt:
140- s.attempt = nil
141- if r.err != nil {
142- log := logging.For(ctx, "atspi")
143- log.Warn().Err(r.err).Msg("accessibility hint targets unavailable, using OCR only")
144- return nil
145- }
146- s.tr = r.tr
178+ case r := <-ch:
179+ s.finish(ctx, r)
147180 case <-timer.C:
181+ log := logging.For(ctx, "atspi")
182+ log.Debug().Dur("after", wait).Msg("accessibility connect attempt abandoned in flight")
148183 case <-ctx.Done():
149184 }
150185 return s.tr
151186 }
152187
188+// finish takes the outcome of the attempt in flight: a transport is kept,
189+// an error starts the backoff from the time the attempt finished.
190+func (s *Source) finish(ctx context.Context, r dialResult) {
191+ s.attempt = nil
192+ if r.err != nil || r.tr == nil {
193+ s.retryAt = r.at.Add(retryEvery)
194+ err := r.err
195+ if err == nil {
196+ err = errors.New("atspi: no transport")
197+ }
198+ log := logging.For(ctx, "atspi")
199+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only")
200+ return
201+ }
202+ s.tr = r.tr
203+}
204+
153205 // drop closes a broken connection; the next attempt reconnects.
154206 func (s *Source) drop() {
207+ s.retryAt = time.Now().Add(retryEvery)
155208 if s.tr != nil {
156209 _ = s.tr.Close()
157210 s.tr = nil
158211 }
159212 }
160213
161-// Close closes the accessibility bus connection, and the one an abandoned
162-// attempt may still deliver.
214+// Close closes the accessibility bus connection, and the one an attempt
215+// still in flight may deliver late. After Close, Targets finds nothing and
216+// never connects. Like Targets it is called by the owner goroutine only.
163217 func (s *Source) Close() error {
218+ if s.closed {
219+ return nil
220+ }
221+ s.closed = true
164222 var err error
165223 if s.tr != nil {
166224 err = s.tr.Close()
@@ -168,6 +226,7 @@ func (s *Source) Close() error {
168226 }
169227 if ch := s.attempt; ch != nil {
170228 s.attempt = nil
229+ // One reaper at most: Close runs once, and one attempt exists.
171230 go func() {
172231 if r := <-ch; r.tr != nil {
173232 _ = r.tr.Close()
@@ -32,26 +32,39 @@ type connector interface {
32 Connect(ctx context.Context) (transport, error)32 Connect(ctx context.Context) (transport, error)
33 }33 }
34 34
35+// stallWarnAfter is how long an attempt may be in flight before it is
36+// reported as a stalled accessibility bus.
37+const stallWarnAfter = 5 * time.Second
38+
35 // dialResult is the outcome of a connection attempt.39 // dialResult is the outcome of a connection attempt.
36 type dialResult struct {40 type dialResult struct {
37 tr transport41 tr transport
38 err error42 err error
43+ at time.Time // when the attempt finished
39 }44 }
40 45
41 // Source reads hint targets from the accessibility tree. It owns one46 // Source reads hint targets from the accessibility tree. It owns one
42-// connection to the accessibility bus and is used by one goroutine at a47+// connection to the accessibility bus. It is NOT safe for concurrent use:
43-// time (the hints worker). The connection is made on the first Targets call48+// it is owned by the hints worker goroutine, which calls Targets and,
49+// when it returns, Close. The connection is made on the first Targets call
44 // that needs it, never at startup, and made again after a failure, at most50 // that needs it, never at startup, and made again after a failure, at most
45 // once every retryEvery; meanwhile Targets finds nothing and OCR takes over.51 // once every retryEvery; meanwhile Targets finds nothing and OCR takes over.
52+//
53+// At most one connection attempt (one goroutine and one socket) exists at
54+// any time: an attempt that outlives the wait of ensure stays in flight, is
55+// polled by the later Targets calls and is never doubled.
46 type Source struct {56 type Source struct {
47 tr transport57 tr transport
48 dial connector58 dial connector
49- // attempt is the outcome of a connection attempt that may still run: a59+ // attempt delivers the outcome of the attempt in flight (buffered, so
50- // silent service is abandoned, not waited for, and its late result is60+ // the attempt goroutine never blocks); nil when none runs.
51- // closed.61+ attempt chan dialResult
52- attempt chan dialResult62+ attemptStarted time.Time
53- attemptDue time.Time63+ warnedStall bool
54- retryAt time.Time64+ retryAt time.Time
65+ closed bool
66+ // wait is how long ensure waits for a new attempt; connectBudget if 0.
67+ wait time.Duration
55 }68 }
56 69
57 // New returns a Source. It does not touch the bus: the connection is made by70 // New returns a Source. It does not touch the bus: the connection is made by
@@ -64,6 +77,11 @@ type busConnector struct{}
64 77
65 func (busConnector) Connect(ctx context.Context) (transport, error) {78 func (busConnector) Connect(ctx context.Context) (transport, error) {
66 log := logging.For(ctx, "atspi")79 log := logging.For(ctx, "atspi")
80+ // zerobus has no deadline or context API: SessionBus and Dial run the
81+ // socket dial, SASL auth and Hello without a bound, and the watchdog
82+ // below cannot exist before the connection does. A stall there is
83+ // bounded by the single-in-flight rule of Source.ensure, not by a
84+ // deadline. Follow-up: a deadline-capable zerobus API.
67 sess, err := zerobus.SessionBus()85 sess, err := zerobus.SessionBus()
68 if err != nil {86 if err != nil {
69 return nil, fmt.Errorf("atspi: session bus: %w", err)87 return nil, fmt.Errorf("atspi: session bus: %w", err)
@@ -111,8 +129,13 @@ func (busConnector) Connect(ctx context.Context) (transport, error) {
111 }129 }
112 130
113 // ensure returns the connection, making one if none exists and the last131 // ensure returns the connection, making one if none exists and the last
114-// attempt is old enough. It waits at most connectBudget for an attempt.132+// attempt is old enough. It waits at most the budget for a new attempt; an
133+// attempt that does not answer in time stays in flight (abandoned, never
134+// doubled) and is polled without blocking by the next calls.
115 func (s *Source) ensure(ctx context.Context) transport {135 func (s *Source) ensure(ctx context.Context) transport {
136+ if s.closed {
137+ return nil
138+ }
116 if s.tr != nil {139 if s.tr != nil {
117 return s.tr140 return s.tr
118 }141 }
@@ -120,47 +143,82 @@ func (s *Source) ensure(ctx context.Context) transport {
120 return nil143 return nil
121 }144 }
122 now := time.Now()145 now := time.Now()
123- if s.attempt == nil {146+ if s.attempt != nil {
124- if now.Before(s.retryAt) {147+ select {
125- return nil148+ case r := <-s.attempt:
149+ s.finish(ctx, r)
150+ default:
151+ if !s.warnedStall && now.Sub(s.attemptStarted) > stallWarnAfter {
152+ s.warnedStall = true
153+ log := logging.For(ctx, "atspi")
154+ log.Warn().Dur("for", now.Sub(s.attemptStarted)).Msg("accessibility bus not answering")
155+ }
126 }156 }
127- s.retryAt = now.Add(retryEvery)157+ return s.tr
128- s.attemptDue = now.Add(connectBudget)158+ }
129- ch := make(chan dialResult, 1)159+ if now.Before(s.retryAt) {
130- s.attempt = ch160+ return nil
131- go func() {
132- tr, err := s.dial.Connect(ctx)
133- ch <- dialResult{tr, err}
134- }()
135 }161 }
136- timer := time.NewTimer(time.Until(s.attemptDue))162+ ch := make(chan dialResult, 1)
163+ s.attempt = ch
164+ s.attemptStarted = now
165+ s.warnedStall = false
166+ dial := s.dial
167+ go func() {
168+ tr, err := dial.Connect(ctx)
169+ ch <- dialResult{tr: tr, err: err, at: time.Now()}
170+ }()
171+ wait := s.wait
172+ if wait <= 0 {
173+ wait = connectBudget
174+ }
175+ timer := time.NewTimer(wait)
137 defer timer.Stop()176 defer timer.Stop()
138 select {177 select {
139- case r := <-s.attempt:178+ case r := <-ch:
140- s.attempt = nil179+ s.finish(ctx, r)
141- if r.err != nil {
142- log := logging.For(ctx, "atspi")
143- log.Warn().Err(r.err).Msg("accessibility hint targets unavailable, using OCR only")
144- return nil
145- }
146- s.tr = r.tr
147 case <-timer.C:180 case <-timer.C:
181+ log := logging.For(ctx, "atspi")
182+ log.Debug().Dur("after", wait).Msg("accessibility connect attempt abandoned in flight")
148 case <-ctx.Done():183 case <-ctx.Done():
149 }184 }
150 return s.tr185 return s.tr
151 }186 }
152 187
188+// finish takes the outcome of the attempt in flight: a transport is kept,
189+// an error starts the backoff from the time the attempt finished.
190+func (s *Source) finish(ctx context.Context, r dialResult) {
191+ s.attempt = nil
192+ if r.err != nil || r.tr == nil {
193+ s.retryAt = r.at.Add(retryEvery)
194+ err := r.err
195+ if err == nil {
196+ err = errors.New("atspi: no transport")
197+ }
198+ log := logging.For(ctx, "atspi")
199+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only")
200+ return
201+ }
202+ s.tr = r.tr
203+}
204+
153 // drop closes a broken connection; the next attempt reconnects.205 // drop closes a broken connection; the next attempt reconnects.
154 func (s *Source) drop() {206 func (s *Source) drop() {
207+ s.retryAt = time.Now().Add(retryEvery)
155 if s.tr != nil {208 if s.tr != nil {
156 _ = s.tr.Close()209 _ = s.tr.Close()
157 s.tr = nil210 s.tr = nil
158 }211 }
159 }212 }
160 213
161-// Close closes the accessibility bus connection, and the one an abandoned214+// Close closes the accessibility bus connection, and the one an attempt
162-// attempt may still deliver.215+// still in flight may deliver late. After Close, Targets finds nothing and
216+// never connects. Like Targets it is called by the owner goroutine only.
163 func (s *Source) Close() error {217 func (s *Source) Close() error {
218+ if s.closed {
219+ return nil
220+ }
221+ s.closed = true
164 var err error222 var err error
165 if s.tr != nil {223 if s.tr != nil {
166 err = s.tr.Close()224 err = s.tr.Close()
@@ -168,6 +226,7 @@ func (s *Source) Close() error {
168 }226 }
169 if ch := s.attempt; ch != nil {227 if ch := s.attempt; ch != nil {
170 s.attempt = nil228 s.attempt = nil
229+ // One reaper at most: Close runs once, and one attempt exists.
171 go func() {230 go func() {
172 if r := <-ch; r.tr != nil {231 if r := <-ch; r.tr != nil {
173 _ = r.tr.Close()232 _ = r.tr.Close()
M internal/adapters/atspi/source_test.go +86 -9
@@ -215,24 +215,101 @@ func TestBrokenConnectionIsDropped(t *testing.T) {
215215 require.Empty(t, targets) // backoff: no new attempt yet
216216 }
217217
218-// A silent service cannot hold Targets beyond the connect budget.
219-func TestConnectIsBounded(t *testing.T) {
218+const testWait = 20 * time.Millisecond
219+
220+// blockedConnector returns a connector whose Connect blocks until release is
221+func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector {
222+ t.Helper()
223+ c := newMockconnector(t)
224+ c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
225+ <-release
226+ return tr, err
227+ }).Times(times)
228+ return c
229+}
230+
231+// A silent service cannot hold Targets beyond the wait, is not doubled while
232+// in flight, and its late transport is used.
233+func TestConnectIsBoundedAndNotDoubled(t *testing.T) {
220234 release := make(chan struct{})
221235 tr := newMocktransport(t)
222- tr.EXPECT().Close().Return(nil).Once()
236+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
237+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
238+ c := blockedConnector(t, release, tr, nil, 1)
239+ s := &Source{dial: c, wait: testWait}
240+ t0 := time.Now()
241+ targets, _, err := s.Targets(context.Background(), oneWindow, false)
242+ require.NoError(t, err)
243+ require.Empty(t, targets)
244+ require.Less(t, time.Since(t0), testWait+200*time.Millisecond)
245+ require.NotNil(t, s.attempt)
246+ // Still in flight: no second Connect (Times(1)), and no blocking.
247+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
248+ require.NoError(t, err)
249+ require.Empty(t, targets)
250+ // It connects late: the next call uses it.
251+ close(release)
252+ require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
253+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
254+ require.NoError(t, err)
255+ require.Len(t, targets, 1)
256+ require.Nil(t, s.attempt)
257+}
258+
259+// An abandoned attempt that fails applies the backoff, then one new attempt.
260+func TestAbandonedAttemptErrorBacksOff(t *testing.T) {
261+ release := make(chan struct{})
262+ tr := newMocktransport(t)
263+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
264+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
223265 c := newMockconnector(t)
224266 c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
225267 <-release
226- return tr, nil
268+ return nil, errors.New("down")
227269 }).Once()
228- s := &Source{dial: c}
229- t0 := time.Now()
270+ c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
271+ s := &Source{dial: c, wait: testWait}
230272 targets, _, err := s.Targets(context.Background(), oneWindow, false)
231273 require.NoError(t, err)
232274 require.Empty(t, targets)
233- require.Less(t, time.Since(t0), connectBudget+200*time.Millisecond)
234- // The late result is closed with the source.
275+ close(release)
276+ require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
277+ // The error is collected; the backoff runs from its finish time.
278+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
279+ require.NoError(t, err)
280+ require.Empty(t, targets)
281+ require.Nil(t, s.attempt)
282+ require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second)
283+ targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect
284+ require.NoError(t, err)
285+ require.Empty(t, targets)
286+ s.retryAt = time.Now().Add(-time.Second)
287+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
288+ require.NoError(t, err)
289+ require.Len(t, targets, 1)
290+}
291+
292+// Close with an attempt in flight closes the late transport; afterwards
293+// Targets finds nothing and never connects.
294+func TestCloseReapsLateTransport(t *testing.T) {
295+ release := make(chan struct{})
296+ closed := make(chan struct{})
297+ tr := newMocktransport(t)
298+ tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
299+ c := blockedConnector(t, release, tr, nil, 1)
300+ s := &Source{dial: c, wait: testWait}
301+ _, _, err := s.Targets(context.Background(), oneWindow, false)
302+ require.NoError(t, err)
235303 require.NoError(t, s.Close())
236304 close(release)
237- require.Eventually(t, func() bool { return tr.AssertExpectations(&testing.T{}) }, time.Second, 5*time.Millisecond)
305+ select {
306+ case <-closed:
307+ case <-time.After(5 * time.Second):
308+ t.Fatal("late transport not closed")
309+ }
310+ targets, covered, err := s.Targets(context.Background(), oneWindow, false)
311+ require.NoError(t, err)
312+ require.Empty(t, targets)
313+ require.Empty(t, covered)
314+ require.NoError(t, s.Close()) // idempotent
238315 }
@@ -215,24 +215,101 @@ func TestBrokenConnectionIsDropped(t *testing.T) {
215 require.Empty(t, targets) // backoff: no new attempt yet215 require.Empty(t, targets) // backoff: no new attempt yet
216 }216 }
217 217
218-// A silent service cannot hold Targets beyond the connect budget.218+const testWait = 20 * time.Millisecond
219-func TestConnectIsBounded(t *testing.T) {219+
220+// blockedConnector returns a connector whose Connect blocks until release is
221+func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector {
222+ t.Helper()
223+ c := newMockconnector(t)
224+ c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
225+ <-release
226+ return tr, err
227+ }).Times(times)
228+ return c
229+}
230+
231+// A silent service cannot hold Targets beyond the wait, is not doubled while
232+// in flight, and its late transport is used.
233+func TestConnectIsBoundedAndNotDoubled(t *testing.T) {
220 release := make(chan struct{})234 release := make(chan struct{})
221 tr := newMocktransport(t)235 tr := newMocktransport(t)
222- tr.EXPECT().Close().Return(nil).Once()236+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
237+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
238+ c := blockedConnector(t, release, tr, nil, 1)
239+ s := &Source{dial: c, wait: testWait}
240+ t0 := time.Now()
241+ targets, _, err := s.Targets(context.Background(), oneWindow, false)
242+ require.NoError(t, err)
243+ require.Empty(t, targets)
244+ require.Less(t, time.Since(t0), testWait+200*time.Millisecond)
245+ require.NotNil(t, s.attempt)
246+ // Still in flight: no second Connect (Times(1)), and no blocking.
247+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
248+ require.NoError(t, err)
249+ require.Empty(t, targets)
250+ // It connects late: the next call uses it.
251+ close(release)
252+ require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
253+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
254+ require.NoError(t, err)
255+ require.Len(t, targets, 1)
256+ require.Nil(t, s.attempt)
257+}
258+
259+// An abandoned attempt that fails applies the backoff, then one new attempt.
260+func TestAbandonedAttemptErrorBacksOff(t *testing.T) {
261+ release := make(chan struct{})
262+ tr := newMocktransport(t)
263+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
264+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
223 c := newMockconnector(t)265 c := newMockconnector(t)
224 c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {266 c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
225 <-release267 <-release
226- return tr, nil268+ return nil, errors.New("down")
227 }).Once()269 }).Once()
228- s := &Source{dial: c}270+ c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
229- t0 := time.Now()271+ s := &Source{dial: c, wait: testWait}
230 targets, _, err := s.Targets(context.Background(), oneWindow, false)272 targets, _, err := s.Targets(context.Background(), oneWindow, false)
231 require.NoError(t, err)273 require.NoError(t, err)
232 require.Empty(t, targets)274 require.Empty(t, targets)
233- require.Less(t, time.Since(t0), connectBudget+200*time.Millisecond)275+ close(release)
234- // The late result is closed with the source.276+ require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
277+ // The error is collected; the backoff runs from its finish time.
278+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
279+ require.NoError(t, err)
280+ require.Empty(t, targets)
281+ require.Nil(t, s.attempt)
282+ require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second)
283+ targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect
284+ require.NoError(t, err)
285+ require.Empty(t, targets)
286+ s.retryAt = time.Now().Add(-time.Second)
287+ targets, _, err = s.Targets(context.Background(), oneWindow, false)
288+ require.NoError(t, err)
289+ require.Len(t, targets, 1)
290+}
291+
292+// Close with an attempt in flight closes the late transport; afterwards
293+// Targets finds nothing and never connects.
294+func TestCloseReapsLateTransport(t *testing.T) {
295+ release := make(chan struct{})
296+ closed := make(chan struct{})
297+ tr := newMocktransport(t)
298+ tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
299+ c := blockedConnector(t, release, tr, nil, 1)
300+ s := &Source{dial: c, wait: testWait}
301+ _, _, err := s.Targets(context.Background(), oneWindow, false)
302+ require.NoError(t, err)
235 require.NoError(t, s.Close())303 require.NoError(t, s.Close())
236 close(release)304 close(release)
237- require.Eventually(t, func() bool { return tr.AssertExpectations(&testing.T{}) }, time.Second, 5*time.Millisecond)305+ select {
306+ case <-closed:
307+ case <-time.After(5 * time.Second):
308+ t.Fatal("late transport not closed")
309+ }
310+ targets, covered, err := s.Targets(context.Background(), oneWindow, false)
311+ require.NoError(t, err)
312+ require.Empty(t, targets)
313+ require.Empty(t, covered)
314+ require.NoError(t, s.Close()) // idempotent
238 }315 }
M internal/adapters/hints/hints.go +7 -0
@@ -43,6 +43,9 @@ func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
4343 // returns the targets (logical output-local) and the windows they cover.
4444 type targetSource interface {
4545 Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)
46+ // Close releases the source. It is called once, by the worker goroutine,
47+ // when the worker returns; the source is not safe for concurrent use.
48+ Close() error
4649 }
4750
4851 // atspiResult is what the source found for one request.
@@ -77,6 +80,10 @@ type frame struct {
7780
7881 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
7982 w := &worker{dumpDir: opts.DumpDir, source: opts.Source}
83+ if opts.Source != nil {
84+ // The worker goroutine is the only one to touch the source.
85+ defer func() { _ = opts.Source.Close() }()
86+ }
8087 for {
8188 select {
8289 case <-ctx.Done():
@@ -43,6 +43,9 @@ func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
43 // returns the targets (logical output-local) and the windows they cover.43 // returns the targets (logical output-local) and the windows they cover.
44 type targetSource interface {44 type targetSource interface {
45 Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)45 Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)
46+ // Close releases the source. It is called once, by the worker goroutine,
47+ // when the worker returns; the source is not safe for concurrent use.
48+ Close() error
46 }49 }
47 50
48 // atspiResult is what the source found for one request.51 // atspiResult is what the source found for one request.
@@ -77,6 +80,10 @@ type frame struct {
77 80
78 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {81 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
79 w := &worker{dumpDir: opts.DumpDir, source: opts.Source}82 w := &worker{dumpDir: opts.DumpDir, source: opts.Source}
83+ if opts.Source != nil {
84+ // The worker goroutine is the only one to touch the source.
85+ defer func() { _ = opts.Source.Close() }()
86+ }
80 for {87 for {
81 select {88 select {
82 case <-ctx.Done():89 case <-ctx.Done():
M internal/adapters/hints/hints_test.go +3 -0
@@ -305,6 +305,7 @@ func TestHintDumpRetention(t *testing.T) {
305305 func TestScanMergesSource(t *testing.T) {
306306 pix, w, hgt := loadBGRA(t)
307307 src := newMocktargetSource(t)
308+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
308309 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}
309310 at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}
310311 src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()
@@ -327,6 +328,7 @@ func TestScanSourceErrorUsesOCR(t *testing.T) {
327328 }
328329 pix, w, hgt := loadBGRA(t)
329330 src := newMocktargetSource(t)
331+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
330332 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
331333 src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()
332334 h := startWith(t, 5*time.Second, Options{Source: src})
@@ -394,6 +396,7 @@ func TestNoTesseractSkipsCapture(t *testing.T) {
394396 covered := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 30, W: 5, H: 5}}
395397 wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}, {ID: 2, Rect: ports.Rect{X: 20, W: 20, H: 20}}}
396398 src := newMocktargetSource(t)
399+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
397400 src.EXPECT().Targets(mock.Anything, wins, false).Return([]ports.HintTarget{button, covered}, map[ports.WindowID]bool{2: true}, nil).Times(2)
398401 h := startWith(t, time.Second, Options{Source: src})
399402 for seq := uint64(1); seq <= 2; seq++ {
@@ -305,6 +305,7 @@ func TestHintDumpRetention(t *testing.T) {
305 func TestScanMergesSource(t *testing.T) {305 func TestScanMergesSource(t *testing.T) {
306 pix, w, hgt := loadBGRA(t)306 pix, w, hgt := loadBGRA(t)
307 src := newMocktargetSource(t)307 src := newMocktargetSource(t)
308+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
308 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}309 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}
309 at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}310 at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}
310 src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()311 src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()
@@ -327,6 +328,7 @@ func TestScanSourceErrorUsesOCR(t *testing.T) {
327 }328 }
328 pix, w, hgt := loadBGRA(t)329 pix, w, hgt := loadBGRA(t)
329 src := newMocktargetSource(t)330 src := newMocktargetSource(t)
331+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
330 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}332 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
331 src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()333 src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()
332 h := startWith(t, 5*time.Second, Options{Source: src})334 h := startWith(t, 5*time.Second, Options{Source: src})
@@ -394,6 +396,7 @@ func TestNoTesseractSkipsCapture(t *testing.T) {
394 covered := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 30, W: 5, H: 5}}396 covered := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 30, W: 5, H: 5}}
395 wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}, {ID: 2, Rect: ports.Rect{X: 20, W: 20, H: 20}}}397 wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}, {ID: 2, Rect: ports.Rect{X: 20, W: 20, H: 20}}}
396 src := newMocktargetSource(t)398 src := newMocktargetSource(t)
399+ src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
397 src.EXPECT().Targets(mock.Anything, wins, false).Return([]ports.HintTarget{button, covered}, map[ports.WindowID]bool{2: true}, nil).Times(2)400 src.EXPECT().Targets(mock.Anything, wins, false).Return([]ports.HintTarget{button, covered}, map[ports.WindowID]bool{2: true}, nil).Times(2)
398 h := startWith(t, time.Second, Options{Source: src})401 h := startWith(t, time.Second, Options{Source: src})
399 for seq := uint64(1); seq <= 2; seq++ {402 for seq := uint64(1); seq <= 2; seq++ {
M internal/adapters/hints/target_source_mock_test.go +44 -0
@@ -47,6 +47,50 @@ func (_m *mocktargetSource) EXPECT() *mocktargetSource_Expecter {
4747 return &mocktargetSource_Expecter{mock: &_m.Mock}
4848 }
4949
50+// Close provides a mock function for the type mocktargetSource
51+func (_mock *mocktargetSource) Close() error {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Close")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func() error); ok {
60+ r0 = returnFunc()
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// mocktargetSource_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
68+type mocktargetSource_Close_Call struct {
69+ *mock.Call
70+}
71+
72+// Close is a helper method to define mock.On call
73+func (_e *mocktargetSource_Expecter) Close() *mocktargetSource_Close_Call {
74+ return &mocktargetSource_Close_Call{Call: _e.mock.On("Close")}
75+}
76+
77+func (_c *mocktargetSource_Close_Call) Run(run func()) *mocktargetSource_Close_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ run()
80+ })
81+ return _c
82+}
83+
84+func (_c *mocktargetSource_Close_Call) Return(err error) *mocktargetSource_Close_Call {
85+ _c.Call.Return(err)
86+ return _c
87+}
88+
89+func (_c *mocktargetSource_Close_Call) RunAndReturn(run func() error) *mocktargetSource_Close_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
5094 // Targets provides a mock function for the type mocktargetSource
5195 func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
5296 ret := _mock.Called(ctx, windows, linksOnly)
@@ -47,6 +47,50 @@ func (_m *mocktargetSource) EXPECT() *mocktargetSource_Expecter {
47 return &mocktargetSource_Expecter{mock: &_m.Mock}47 return &mocktargetSource_Expecter{mock: &_m.Mock}
48 }48 }
49 49
50+// Close provides a mock function for the type mocktargetSource
51+func (_mock *mocktargetSource) Close() error {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Close")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func() error); ok {
60+ r0 = returnFunc()
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// mocktargetSource_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
68+type mocktargetSource_Close_Call struct {
69+ *mock.Call
70+}
71+
72+// Close is a helper method to define mock.On call
73+func (_e *mocktargetSource_Expecter) Close() *mocktargetSource_Close_Call {
74+ return &mocktargetSource_Close_Call{Call: _e.mock.On("Close")}
75+}
76+
77+func (_c *mocktargetSource_Close_Call) Run(run func()) *mocktargetSource_Close_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ run()
80+ })
81+ return _c
82+}
83+
84+func (_c *mocktargetSource_Close_Call) Return(err error) *mocktargetSource_Close_Call {
85+ _c.Call.Return(err)
86+ return _c
87+}
88+
89+func (_c *mocktargetSource_Close_Call) RunAndReturn(run func() error) *mocktargetSource_Close_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
50 // Targets provides a mock function for the type mocktargetSource94 // Targets provides a mock function for the type mocktargetSource
51 func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {95 func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
52 ret := _mock.Called(ctx, windows, linksOnly)96 ret := _mock.Called(ctx, windows, linksOnly)
M internal/app/hintdebug.go +2 -2
@@ -16,9 +16,9 @@ import (
1616 // by the first scan; without it the worker reads windows with OCR only.
1717 func hintOptions(ctx context.Context) hints.Options {
1818 opts := hintDump(ctx)
19- // The source connects on its first scan, and again after a failure.
19+ // The source connects on its first scan, and again after a failure. The
20+ // hints worker owns it and closes it when it returns.
2021 src := atspi.New()
21- context.AfterFunc(ctx, func() { _ = src.Close() })
2222 opts.Source = src
2323 return opts
2424 }
@@ -16,9 +16,9 @@ import (
16 // by the first scan; without it the worker reads windows with OCR only.16 // by the first scan; without it the worker reads windows with OCR only.
17 func hintOptions(ctx context.Context) hints.Options {17 func hintOptions(ctx context.Context) hints.Options {
18 opts := hintDump(ctx)18 opts := hintDump(ctx)
19- // The source connects on its first scan, and again after a failure.19+ // The source connects on its first scan, and again after a failure. The
20+ // hints worker owns it and closes it when it returns.
20 src := atspi.New()21 src := atspi.New()
21- context.AfterFunc(ctx, func() { _ = src.Close() })
22 opts.Source = src22 opts.Source = src
23 return opts23 return opts
24 }24 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close