detached 35d84dc

fix(tray): one deadline for opening a menu, close it again when it fails

AboutToShow, Event(opened) and GetLayout share a 4 s budget. If the application was told the menu opened and no menu reaches the bar (timeout, bad or empty layout), it is sent Event(0, closed) and the menu is forgotten.
commit
35d84dc7b30387802d488d757d99cce846decad7
author
brice · · 2026-10-09 06:42 UTC
parent
559fa4c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +122 -15
M internal/tray/control.go +54 -14
@@ -204,6 +204,10 @@ type control struct {
204204 tipTimer *time.Timer
205205 hov hover
206206 menu *lastMenu
207+
208+ // deadline, when set, ends every call of the sequence in progress
209+ // (openMenu): a call waits for the smaller of callTimeout and what is left.
210+ deadline time.Time
207211 }
208212
209213 // hover is the icon the pointer rests on, and whether its tooltip is shown.
@@ -432,8 +436,14 @@ func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerob
432436 }
433437 return err
434438 }
439+ wait := callTimeout
440+ if !c.deadline.IsZero() {
441+ if wait = min(wait, time.Until(c.deadline)); wait <= 0 {
442+ return errBudget
443+ }
444+ }
435445 args(conn.NewCall(dest, path, iface, member, sig))
436- timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
446+ timer := time.AfterFunc(wait, func() { _ = conn.Close() })
437447 m, err := conn.Call()
438448 if err == nil && reply != nil {
439449 reply(m)
@@ -461,6 +471,18 @@ func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerob
461471 return err
462472 }
463473
474+// errBudget is returned by a call that did not start because the time for its
475+// sequence ran out.
476+var errBudget = errors.New("tray: out of time")
477+
478+// menuBudget is how long opening a menu may take in all, whatever the number
479+// of calls it makes. A variable for the tests.
480+var menuBudget = 4 * time.Second
481+
482+// closeBudget is the time given to tell an application its menu will not be
483+// shown after all.
484+const closeBudget = time.Second
485+
464486 // transportFailed reports whether err means the call did not complete, as
465487 // opposed to the application answering with an error.
466488 func transportFailed(err error) bool {
@@ -470,24 +492,41 @@ func transportFailed(err error) bool {
470492
471493 // openMenu shows the application's menu for the press numbered token: its
472494 // dbusmenu when it has one, else it asks the application to show its own.
495+// The calls share one deadline (menuBudget). When they fail after the
496+// application was told "opened", it is told "closed" and the menu forgotten.
473497 func (c *control) openMenu(tg Target, token uint32) {
474498 if tg.Menu == "" || tg.Menu == noMenuPath {
475499 c.contextMenu(tg)
476500 return
477501 }
502+ c.menu = nil // an older menu is superseded
503+ c.deadline = time.Now().Add(menuBudget)
504+ defer func() { c.deadline = time.Time{} }()
505+
478506 // Ignore refusals: many applications do not implement these.
479507 err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
480- if transportFailed(err) {
508+ if err != nil && (transportFailed(err) || errors.Is(err, errBudget)) {
481509 return
482510 }
483511 err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
484- if transportFailed(err) {
485- return
512+ if errors.Is(err, errBudget) {
513+ return // never sent
486514 }
515+ // From here on the application may believe its menu is open, even if the
516+ // call timed out: it may have been delivered.
517+ if (err != nil && transportFailed(err)) || !c.sendMenu(tg, token) {
518+ c.deadline = time.Now().Add(closeBudget)
519+ c.menuEvent(tg.Dest, tg.Menu, 0, "closed")
520+ c.menu = nil
521+ }
522+}
523+
524+// sendMenu reads the menu of tg and writes it for the bar. It reports false
525+// when there is no menu to show.
526+func (c *control) sendMenu(tg Target, token uint32) bool {
487527 var items []menuItem
488- var ids []int32
489528 var decodeErr error
490- err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
529+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
491530 e.Int32(0)
492531 e.Int32(-1)
493532 a := e.BeginArray('s')
@@ -497,26 +536,27 @@ func (c *control) openMenu(tg Target, token uint32) {
497536 decodeErr = errors.New("unexpected layout signature " + m.Signature)
498537 return
499538 }
500- items, ids, decodeErr = decodeLayout(m.Body())
539+ items, _, decodeErr = decodeLayout(m.Body())
501540 })
502541 if err != nil {
503- return
542+ return false
504543 }
505544 if decodeErr != nil {
506545 c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
507- return
546+ return false
508547 }
509548 if len(items) == 0 {
510549 c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
511- return
550+ return false
512551 }
513- line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
514- if encErr != nil {
515- c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr)
516- return
552+ line, ids, err := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
553+ if err != nil {
554+ c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", err)
555+ return false
517556 }
518557 c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
519558 c.writeLine(line)
559+ return true
520560 }
521561
522562 // menuEvent sends a dbusmenu Event with no data.
@@ -204,6 +204,10 @@ type control struct {
204 tipTimer *time.Timer204 tipTimer *time.Timer
205 hov hover205 hov hover
206 menu *lastMenu206 menu *lastMenu
207+
208+ // deadline, when set, ends every call of the sequence in progress
209+ // (openMenu): a call waits for the smaller of callTimeout and what is left.
210+ deadline time.Time
207 }211 }
208 212
209 // hover is the icon the pointer rests on, and whether its tooltip is shown.213 // hover is the icon the pointer rests on, and whether its tooltip is shown.
@@ -432,8 +436,14 @@ func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerob
432 }436 }
433 return err437 return err
434 }438 }
439+ wait := callTimeout
440+ if !c.deadline.IsZero() {
441+ if wait = min(wait, time.Until(c.deadline)); wait <= 0 {
442+ return errBudget
443+ }
444+ }
435 args(conn.NewCall(dest, path, iface, member, sig))445 args(conn.NewCall(dest, path, iface, member, sig))
436- timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })446+ timer := time.AfterFunc(wait, func() { _ = conn.Close() })
437 m, err := conn.Call()447 m, err := conn.Call()
438 if err == nil && reply != nil {448 if err == nil && reply != nil {
439 reply(m)449 reply(m)
@@ -461,6 +471,18 @@ func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerob
461 return err471 return err
462 }472 }
463 473
474+// errBudget is returned by a call that did not start because the time for its
475+// sequence ran out.
476+var errBudget = errors.New("tray: out of time")
477+
478+// menuBudget is how long opening a menu may take in all, whatever the number
479+// of calls it makes. A variable for the tests.
480+var menuBudget = 4 * time.Second
481+
482+// closeBudget is the time given to tell an application its menu will not be
483+// shown after all.
484+const closeBudget = time.Second
485+
464 // transportFailed reports whether err means the call did not complete, as486 // transportFailed reports whether err means the call did not complete, as
465 // opposed to the application answering with an error.487 // opposed to the application answering with an error.
466 func transportFailed(err error) bool {488 func transportFailed(err error) bool {
@@ -470,24 +492,41 @@ func transportFailed(err error) bool {
470 492
471 // openMenu shows the application's menu for the press numbered token: its493 // openMenu shows the application's menu for the press numbered token: its
472 // dbusmenu when it has one, else it asks the application to show its own.494 // dbusmenu when it has one, else it asks the application to show its own.
495+// The calls share one deadline (menuBudget). When they fail after the
496+// application was told "opened", it is told "closed" and the menu forgotten.
473 func (c *control) openMenu(tg Target, token uint32) {497 func (c *control) openMenu(tg Target, token uint32) {
474 if tg.Menu == "" || tg.Menu == noMenuPath {498 if tg.Menu == "" || tg.Menu == noMenuPath {
475 c.contextMenu(tg)499 c.contextMenu(tg)
476 return500 return
477 }501 }
502+ c.menu = nil // an older menu is superseded
503+ c.deadline = time.Now().Add(menuBudget)
504+ defer func() { c.deadline = time.Time{} }()
505+
478 // Ignore refusals: many applications do not implement these.506 // Ignore refusals: many applications do not implement these.
479 err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)507 err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
480- if transportFailed(err) {508+ if err != nil && (transportFailed(err) || errors.Is(err, errBudget)) {
481 return509 return
482 }510 }
483 err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")511 err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
484- if transportFailed(err) {512+ if errors.Is(err, errBudget) {
485- return513+ return // never sent
486 }514 }
515+ // From here on the application may believe its menu is open, even if the
516+ // call timed out: it may have been delivered.
517+ if (err != nil && transportFailed(err)) || !c.sendMenu(tg, token) {
518+ c.deadline = time.Now().Add(closeBudget)
519+ c.menuEvent(tg.Dest, tg.Menu, 0, "closed")
520+ c.menu = nil
521+ }
522+}
523+
524+// sendMenu reads the menu of tg and writes it for the bar. It reports false
525+// when there is no menu to show.
526+func (c *control) sendMenu(tg Target, token uint32) bool {
487 var items []menuItem527 var items []menuItem
488- var ids []int32
489 var decodeErr error528 var decodeErr error
490- err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {529+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
491 e.Int32(0)530 e.Int32(0)
492 e.Int32(-1)531 e.Int32(-1)
493 a := e.BeginArray('s')532 a := e.BeginArray('s')
@@ -497,26 +536,27 @@ func (c *control) openMenu(tg Target, token uint32) {
497 decodeErr = errors.New("unexpected layout signature " + m.Signature)536 decodeErr = errors.New("unexpected layout signature " + m.Signature)
498 return537 return
499 }538 }
500- items, ids, decodeErr = decodeLayout(m.Body())539+ items, _, decodeErr = decodeLayout(m.Body())
501 })540 })
502 if err != nil {541 if err != nil {
503- return542+ return false
504 }543 }
505 if decodeErr != nil {544 if decodeErr != nil {
506 c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)545 c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
507- return546+ return false
508 }547 }
509 if len(items) == 0 {548 if len(items) == 0 {
510 c.log.Debug("tray: the menu is empty", "dest", tg.Dest)549 c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
511- return550+ return false
512 }551 }
513- line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})552+ line, ids, err := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
514- if encErr != nil {553+ if err != nil {
515- c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr)554+ c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", err)
516- return555+ return false
517 }556 }
518 c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}557 c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
519 c.writeLine(line)558 c.writeLine(line)
559+ return true
520 }560 }
521 561
522 // menuEvent sends a dbusmenu Event with no data.562 // menuEvent sends a dbusmenu Event with no data.
M internal/tray/dbusmenu_test.go +66 -0
@@ -82,6 +82,7 @@ func (f *fakeItem) menuCall(m *zerobus.Message) bool {
8282 default:
8383 return true
8484 }
85+ time.Sleep(f.mslow[m.Member]) // a slow application: the reply is built, not sent yet
8586 _, err := f.c.Send()
8687 return err == nil
8788 }
@@ -325,3 +326,68 @@ func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
325326 <-done
326327 return items, ids
327328 }
329+
330+// A slow application cannot hold the tray for more than the shared budget, and
331+// it is told the menu is closed when it was told it opened.
332+func TestSlowMenuUsesOneDeadlineAndIsClosed(t *testing.T) {
333+ old := menuBudget
334+ menuBudget = 1200 * time.Millisecond
335+ t.Cleanup(func() { menuBudget = old })
336+
337+ steam, _, in, out := setup(t)
338+ menuReady(t, steam, in, out, sampleLayout()) // the tray knows the menu now
339+ in.send(t, "menu-closed 9")
340+ expectMenuCall(t, steam, "Event(0,closed)")
341+
342+ // Every call takes 500 ms: each is under the 2 s call limit, and the
343+ // sequence runs out of its 1.2 s while GetLayout is waiting.
344+ steam.run(t, func() {
345+ steam.mslow = map[string]time.Duration{"AboutToShow": 500 * time.Millisecond, "Event": 500 * time.Millisecond, "GetLayout": 500 * time.Millisecond}
346+ })
347+ start := time.Now()
348+ in.send(t, "click right 0 20")
349+ expectMenuCall(t, steam, "AboutToShow(0)")
350+ expectMenuCall(t, steam, "Event(0,opened)")
351+ expectMenuCall(t, steam, "GetLayout")
352+ // GetLayout ran out of budget: the application hears "closed".
353+ expectMenuCall(t, steam, "Event(0,closed)")
354+ if d := time.Since(start); d > 3*time.Second {
355+ t.Fatalf("opening the menu took %v", d)
356+ }
357+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
358+ // The menu is forgotten: a late choice does nothing.
359+ in.send(t, "menu-activate 20 1")
360+ expectNoMenuCall(t, steam)
361+}
362+
363+// An application with an empty menu is also told that it closed.
364+func TestEmptyMenuIsClosedAgain(t *testing.T) {
365+ steam, _, in, out := setup(t)
366+ menuReady(t, steam, in, out, sampleLayout())
367+ in.send(t, "menu-closed 9")
368+ expectMenuCall(t, steam, "Event(0,closed)")
369+ steam.run(t, func() { steam.menu = &tnode{id: 0} })
370+ // The tray still thinks it has a menu path: the layout is just empty.
371+ in.send(t, "click right 0 21")
372+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
373+ expectMenuCall(t, steam, want)
374+ }
375+ in.send(t, "menu-activate 21 1")
376+ expectNoMenuCall(t, steam)
377+}
378+
379+// noControlLineFor fails when a control line of the given type arrives within d.
380+func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
381+ t.Helper()
382+ timeout := time.After(d)
383+ for {
384+ select {
385+ case s, ok := <-out.ch:
386+ if ok && strings.HasPrefix(s, controlPrefix) && strings.Contains(s, `"type":"`+typ+`"`) {
387+ t.Fatalf("unexpected %s line %q", typ, s)
388+ }
389+ case <-timeout:
390+ return
391+ }
392+ }
393+}
@@ -82,6 +82,7 @@ func (f *fakeItem) menuCall(m *zerobus.Message) bool {
82 default:82 default:
83 return true83 return true
84 }84 }
85+ time.Sleep(f.mslow[m.Member]) // a slow application: the reply is built, not sent yet
85 _, err := f.c.Send()86 _, err := f.c.Send()
86 return err == nil87 return err == nil
87 }88 }
@@ -325,3 +326,68 @@ func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
325 <-done326 <-done
326 return items, ids327 return items, ids
327 }328 }
329+
330+// A slow application cannot hold the tray for more than the shared budget, and
331+// it is told the menu is closed when it was told it opened.
332+func TestSlowMenuUsesOneDeadlineAndIsClosed(t *testing.T) {
333+ old := menuBudget
334+ menuBudget = 1200 * time.Millisecond
335+ t.Cleanup(func() { menuBudget = old })
336+
337+ steam, _, in, out := setup(t)
338+ menuReady(t, steam, in, out, sampleLayout()) // the tray knows the menu now
339+ in.send(t, "menu-closed 9")
340+ expectMenuCall(t, steam, "Event(0,closed)")
341+
342+ // Every call takes 500 ms: each is under the 2 s call limit, and the
343+ // sequence runs out of its 1.2 s while GetLayout is waiting.
344+ steam.run(t, func() {
345+ steam.mslow = map[string]time.Duration{"AboutToShow": 500 * time.Millisecond, "Event": 500 * time.Millisecond, "GetLayout": 500 * time.Millisecond}
346+ })
347+ start := time.Now()
348+ in.send(t, "click right 0 20")
349+ expectMenuCall(t, steam, "AboutToShow(0)")
350+ expectMenuCall(t, steam, "Event(0,opened)")
351+ expectMenuCall(t, steam, "GetLayout")
352+ // GetLayout ran out of budget: the application hears "closed".
353+ expectMenuCall(t, steam, "Event(0,closed)")
354+ if d := time.Since(start); d > 3*time.Second {
355+ t.Fatalf("opening the menu took %v", d)
356+ }
357+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
358+ // The menu is forgotten: a late choice does nothing.
359+ in.send(t, "menu-activate 20 1")
360+ expectNoMenuCall(t, steam)
361+}
362+
363+// An application with an empty menu is also told that it closed.
364+func TestEmptyMenuIsClosedAgain(t *testing.T) {
365+ steam, _, in, out := setup(t)
366+ menuReady(t, steam, in, out, sampleLayout())
367+ in.send(t, "menu-closed 9")
368+ expectMenuCall(t, steam, "Event(0,closed)")
369+ steam.run(t, func() { steam.menu = &tnode{id: 0} })
370+ // The tray still thinks it has a menu path: the layout is just empty.
371+ in.send(t, "click right 0 21")
372+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
373+ expectMenuCall(t, steam, want)
374+ }
375+ in.send(t, "menu-activate 21 1")
376+ expectNoMenuCall(t, steam)
377+}
378+
379+// noControlLineFor fails when a control line of the given type arrives within d.
380+func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
381+ t.Helper()
382+ timeout := time.After(d)
383+ for {
384+ select {
385+ case s, ok := <-out.ch:
386+ if ok && strings.HasPrefix(s, controlPrefix) && strings.Contains(s, `"type":"`+typ+`"`) {
387+ t.Fatalf("unexpected %s line %q", typ, s)
388+ }
389+ case <-timeout:
390+ return
391+ }
392+ }
393+}
M internal/tray/tray_test.go +2 -1
@@ -74,7 +74,8 @@ type fakeItem struct {
7474
7575 // A dbusmenu at /MenuBar, set through run.
7676 menu *tnode
77- mcalls chan string // the dbusmenu calls it received
77+ mcalls chan string // the dbusmenu calls it received
78+ mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
7879 }
7980
8081 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
@@ -74,7 +74,8 @@ type fakeItem struct {
74 74
75 // A dbusmenu at /MenuBar, set through run.75 // A dbusmenu at /MenuBar, set through run.
76 menu *tnode76 menu *tnode
77- mcalls chan string // the dbusmenu calls it received77+ mcalls chan string // the dbusmenu calls it received
78+ mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
78 }79 }
79 80
80 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {81 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {

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 5

Tags 4

↑↓ moveenter openesc close