betterbird: know a composer by its instance alone, and bound a restart's worst case (review of #155)
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered

A composer window whose class is not Betterbird's own was not seen, so a restart could lose its message.
Each step of a restart now has a bound short enough that all of them together, taken in full, stay
under the runtime's 30 s: 3 s for the window tree, 8 s to quit, 3 and 5 s to start, 4 s to see it.
This commit is contained in:
2026-10-09 12:34:06 +02:00
parent 3629089ced
commit 2d2c694306
4 changed files with 58 additions and 7 deletions
@@ -676,12 +676,12 @@ type RestartAnswer struct {
Unit string `json:"unit"`
}
// composerClass is the X instance of a message being written.
var composerClass = regexp.MustCompile(`\("Msgcompose" "eu\.betterbird\.Betterbird"\)`)
// composerClass is the X instance of a message being written, whatever the class beside it.
var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`)
// composers counts the open message windows, from the X window tree.
func (m *Machine) composers(s Session) (int, error) {
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
o := m.cmd(3*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
return 0, err
}
@@ -707,13 +707,23 @@ func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
}
a.Composers = n
a.Ended, a.Killed = m.stopProcs(10*time.Second, m.mainProcs())
a.Ended, a.Killed = m.stopProcs(8*time.Second, m.mainProcs())
if u, err := m.userJS(true); err != nil {
a.UserJS = "not placed: " + err.Error()
} else {
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
}
if err := m.detach(s, restartAs, startBin); err != nil {
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
_ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin)
if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
@@ -100,7 +100,7 @@ func tools() []stdio.Tool {
},
{
Name: "betterbird_restart",
Description: "End Betterbird (asked to quit, forced after 10 s), place the module's user.js when the profile " +
Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " +
"allows it, and start Betterbird again in the operator's desktop session under the account's service " +
"manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " +
"(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " +
@@ -1,6 +1,7 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -139,3 +140,43 @@ func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *te
t.Fatalf("the unit removes its profile when it ends: %v", f.calls)
}
}
func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.answer = func(name string, args []string) Output {
if name == "xwininfo" {
return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"}
}
return Output{}
}
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
t.Fatalf("a composer of another class is still a composer: %v", err)
}
}
func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.stubborn[4000] = true
total := time.Duration(0)
run := f.Run
f.Run = func(ctx context.Context, env []string, name string, args ...string) Output {
// Every command takes its whole timeout: the worst case.
if d, ok := ctx.Deadline(); ok {
total += time.Until(d).Round(time.Second)
}
return run(ctx, env, name, args...)
}
f.Sleep = func(d time.Duration) { total += d }
if _, err := f.Restart(true); err != nil {
t.Fatal(err)
}
if total > 26*time.Second {
t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total)
}
}