pacman starts a round's run only once the node's file holds the round's mirror list, and a refresh never writes after a round opened (ADR 0585 §2, §4; review round 1)
mesh/delivery-group group feat/node-packages rejected: composed together: the checker's verdict at the members' heads (98ce55efa892): unit: i3/cmd/i3-tools.Tes…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: i3/cmd/i3-tools.TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded: loaded map[/tmp/TestAtItsStartTheWatcherReloadsW…

The opener ranked the list and took its turn in the same step, so its upgrade started
before the keeper's change hook wrote /etc/pacman.d/mirrorlist, and a failed write on
any node was only logged. A round's run now writes the list first and checks the file
holds the revision the round opened with; otherwise the run is refused with why and
tried again an hour later, as every refused run is. A refresh asks again for a running
round right before its write, since ranking takes minutes.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-01 16:32:59 +01:00
parent 7375d613b4
commit 3d1a450406
5 changed files with 134 additions and 12 deletions
@@ -26,12 +26,15 @@ type countingRoot struct {
mu sync.Mutex
started []string
mirrors []string
// mirrorsAtStart is the mirror list the node's file held when each transaction started (ADR 0585 §2).
mirrorsAtStart [][]string
}
func (r *countingRoot) Transaction(kind, run string, ignore []string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.started = append(r.started, kind+" "+run)
r.mirrorsAtStart = append(r.mirrorsAtStart, append([]string(nil), r.mirrors...))
return nil
}
func (r *countingRoot) Rollback(string, []RollbackItem) error { return nil }
@@ -83,16 +86,20 @@ func startRoundNode(t *testing.T, ctx context.Context, url, node string, order [
m := machine(run, 1000)
began := time.Now()
m.Now = func() time.Time { return t0.Add(time.Since(began)) }
n := &roundNode{root: &countingRoot{}, signals: make(chan Signal)}
m.ReadFile = func(path string) ([]byte, error) {
switch path {
case OSRelease:
return []byte("7.2.8-arch1-2\n"), nil
case ReflectorConf:
return []byte("--sort rate\n"), nil
case Mirrorlist: // the file holds what the root helper last wrote
if l := n.root.mirrorlist(); l != nil {
return RenderMirrorlist(l), nil
}
}
return nil, errNoFile
}
n := &roundNode{root: &countingRoot{}, signals: make(chan Signal)}
k := &Keeper{Config: KeeperConfig{UpgradeOrder: order, RecordDays: 180, RoundStartMinutes: 120}, Machine: m, Clock: realClock{},
Watcher: fakeWatcher{n.signals}, Logf: func(f string, a ...any) { t.Logf(node+": "+f, a...) }, MakeHooks: keeperHooks,
Root: n.root, Check: func(Run, *Record) Check { return Check{Passed: passes} },
@@ -192,6 +199,13 @@ func TestTheRoundGoesNodeByNodeOnARealBus(t *testing.T) {
eventually(t, "each node wrote the mesh's mirror list", 10*time.Second, func() bool {
return strings.Join(n.root.mirrorlist(), " ") == strings.Join(roundMirrors, " ")
})
// And each started its run only once its file held the round's list, the opener too.
n.root.mu.Lock()
at := n.root.mirrorsAtStart
n.root.mu.Unlock()
if len(at) != 1 || strings.Join(at[0], " ") != strings.Join(roundMirrors, " ") {
t.Errorf("a node's run started with the mirror list file holding %v, want the round's %v", at, roundMirrors)
}
}
}