Compare commits

..
Author SHA1 Message Date
jschoubben 40da368ee3 Date an adopted merge once, by the walk it was adopted from, so a later walk it follows never makes it look newer than a forge merge heard after it (issue 1726, review)
mesh/check pass 40da368ee34e on 25176e1ccdb3 | unit 31 run, 4404 kept | integration 0 run, 4477 kept | over bound: unit | composition passed in 1ms on…
mesh/delivery ready: it delivers once merged
2026-10-24 13:55:10 +02:00
jschoubben 15101c9ce6 Move source.main for a merge the gate caught up after a gap, so the checker re-checks open heads on the trunk that is (issue 1726)
The checker takes a repository's trunk head only from the gate's source.main. On 2026-10-24 gitea's
restarted poll announced mesh-catalog #471's merge while the gate was starting and could not yet tell
whether it held the seat; the gate acknowledged the event unread. It then adopted the controller's walk
of the merge commit, which carries no merge time, so every older merge of the repository looked newer
and source.main was never moved: #472 was checked on the trunk before #471 and its merge refused.

- An event heard before the gate can tell whether it holds the seat is offered again, not taken.
- An adopted merge is dated by its walk's creation, so it settles into source.main as a forge merge does.
- Each time the gate comes to hold the seat it weighs the newest merge of each repository against
  source.main once more, so a merge marked there and never written (as #471) is written; never backwards.
2026-10-24 13:41:56 +02:00
jschoubben 545c72bb59 Merge pull request 'gitea's watcher answers its handshake without waiting for the forge, so a slow forge at a tool-runner restart no longer kills its tools and events (issue 1726)' (#471) from fix/1726-gitea-handshake-not-on-forge into main
Reviewed-on: #471
2026-10-24 11:10:39 +00:00
jschoubben 7afcd0282d Set up the forge's webhook without holding the bundle's import, so a slow forge never costs gitea its handshake and its tools (issue 1726)
At 12:37 on 2026-10-24 node-tools on novox was replaced; gitea's watcher bundle awaited the webhook's set-up on the forge's admin API at the top of its import, outlasted the 10 s handshake and was killed, never to start again. gitea_commit_status, gitea_merge_pull_request and gitea_events were then served nowhere: the forge bridge's calls found no responders and no new pull request was announced.
2026-10-24 13:00:57 +02:00
11 changed files with 296 additions and 70 deletions
@@ -65,7 +65,6 @@ func configFromEnv(getenv func(string) string) (Config, error) {
HostFile: getenv("MESH_FORGE_HOST_FILE"),
OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"),
Port: port,
HostKeyFile: getenv("MESH_GITEA_HOST_KEY_FILE"), // optional
Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"),
PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second},
Runner: execRunner{},
@@ -44,13 +44,10 @@ type Config struct {
// origins, one per line.
HostFile, OriginsFile string
// Port is where the forge's sshd is published on the mesh.
Port int
// HostKeyFile is the forge's own ed25519 host key (the .pub file in its data directory). It is read first; when it
// is unset or unreadable the forge's sshd is asked with ssh-keyscan instead (issue 1939).
HostKeyFile string
Forge forgehttp.Client
Runner Runner
Log func(format string, a ...any)
Port int
Forge forgehttp.Client
Runner Runner
Log func(format string, a ...any)
}
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
@@ -451,30 +448,14 @@ func (s *Service) httpsBase() (string, error) {
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
}
// scan gives the forge's ed25519 host key as the machines' known_hosts line. The key the forge itself holds is read
// from its file first; only when that cannot be read is the forge's sshd asked over the network, and when that fails
// too (the forge's container is restarting) the key read before is kept, as a host key does not change (issue 1939).
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
func (s *Service) scan(ctx context.Context) (string, error) {
host, err := s.hostName()
if err != nil {
return "", err
}
if s.cfg.HostKeyFile != "" {
raw, ferr := os.ReadFile(s.cfg.HostKeyFile)
if ferr == nil {
if line, perr := knownHostLine("127.0.0.1 "+strings.TrimSpace(string(raw)), host, s.cfg.Port); perr == nil {
return line, nil
}
}
}
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
if err != nil {
s.mu.Lock()
kept := s.known
s.mu.Unlock()
if kept != "" {
return kept, nil
}
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
}
return knownHostLine(string(out), host, s.cfg.Port)
@@ -696,42 +696,3 @@ func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) {
}
}
}
// The forge's own host key file is read first: no scan over the network is needed (issue 1939).
func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) {
r := newRig(t)
f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub")
if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil {
t.Fatal(err)
}
r.svc.cfg.HostKeyFile = f
r.runner.err = errors.New("sshd down")
got, err := r.svc.scan(context.Background())
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" {
t.Fatalf("scan = %q, %v", got, err)
}
if r.runner.args != nil {
t.Errorf("the forge's sshd was asked: %v", r.runner.args)
}
}
// A file that cannot be read falls back to the forge's sshd.
func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) {
r := newRig(t)
r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub")
got, err := r.svc.scan(context.Background())
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
t.Fatalf("scan = %q, %v", got, err)
}
}
// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed.
func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) {
r := newRig(t)
r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY"
r.runner.err = errors.New("connection refused")
got, err := r.svc.scan(context.Background())
if err != nil || got != r.svc.known {
t.Fatalf("scan = %q, %v", got, err)
}
}
+5 -1
View File
@@ -589,7 +589,11 @@ if (gitea) {
};
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
await listenForForge(client);
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
void listenForForge(client);
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
-1
View File
@@ -537,7 +537,6 @@
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins",
"MESH_GITEA_SSH_PORT": "222",
"MESH_GITEA_HOST_KEY_FILE": "${dir:data}/ssh/ssh_host_ed25519_key.pub",
"MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host"
}
}
+50
View File
@@ -109,3 +109,53 @@ test("a bundle whose read of the checker's owed is granted late writes the lates
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
});
// **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up
// asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while
// every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never
// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge
// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every
// request and never answers: the import resolves at once all the same, with the bundle's tools registered.
test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const script = `
const http = await import("node:http");
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const forge = http.createServer(() => {});
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-1726-"));
fs.writeFileSync(path.join(dir, "admin.password"), "pw");
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
process.env.MESH_GITEA_STATE_DIR = dir;
process.env.MESH_GITEA_ADMIN_PASSWORD_FILE = path.join(dir, "admin.password");
process.env.MESH_GITEA_HOOK_PORT = "0";
process.env.MESH_GITEA_HOOK_HOST = "127.0.0.1";
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async () => () => {}, publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
await import(${JSON.stringify(index)});
const { collectTools } = await import("@novox/mesh-sdk/tools");
console.log(JSON.stringify({ imported: true, tools: collectTools().flatMap((r) => r.tools.map((t) => t.name)) }));
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" };
for (const k of ["MESH_SERVED_MODULE", "MESH_GITEA_ADMIN_PASSWORD", "MESH_GITEA_CONFIG_FILE"]) delete env[k];
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const ended = new Promise<void>((r) => child.on("exit", () => r()));
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
await ended;
clearTimeout(timer);
const line = out.split("\n").find((l) => l.startsWith("{"));
assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
const got = JSON.parse(line!) as { tools: string[] };
for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) {
assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`);
}
});
@@ -54,6 +54,9 @@ type Holder struct {
seatMu sync.Mutex
seatWhy string
// catchUp asks the next look to weigh again the newest merge of each repository against source.main (SourceCatchUp).
catchUp bool
mu sync.Mutex
deliveries map[string]*Delivery
groups map[string]*Group
@@ -1845,6 +1848,12 @@ func (h *Holder) adopt(w Walk) {
now := h.Now()
d := &Delivery{ID: IDOf(w.Repository, w.Commit), Repository: strings.ToLower(w.Repository), Commit: w.Commit,
Base: w.Branch, MergedAs: w.Commit, Created: now}
// Dated once, by the walk it is adopted from, which follows the merge: a later walk it follows (linkWalk) never redates
// it, so it never looks newer than a merge heard after it (issue 1726, review).
d.MergedAt = w.Created.UTC()
if w.Created.IsZero() {
d.MergedAt = now
}
if old := h.deliveries[d.ID]; old != nil {
if old.State.Final() {
// Never revived: an ended delivery is not the walk's; the walk, held by nothing, is listed by
@@ -0,0 +1,161 @@
package main
import (
"errors"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 1726 (2026-10-24, mesh-catalog #471 and #472): the checker takes a repository's trunk head only from source.main
// (mesh-delivery modules/mesh-delivery-checker main.go), and the gate moves source.main only on a merge it hears. While
// gitea's watcher was dead, the operator merged #471; the restarted watcher's poll announced its pull.merged while the gate
// was starting, before it could tell whether it held the seat, and the gate acknowledged it unread. The gate then adopted the
// controller's walk of the merge commit, which has no merge time, so every older merge of the repository looked newer and
// source.main was never moved: the checker judged #472 on the trunk before #471, and the gate refused its merge.
const (
i1726Repo = "novox/mesh-catalog"
i1726Head1 = "aaaa000000000000000000000000000000000001"
i1726Merge1 = "dddd000000000000000000000000000000000002"
i1726Merge2 = "bbbb000000000000000000000000000000000003"
)
// 1726c/AC1: a merge into main whose pull.merged the gate never heard, caught up as the controller's walk of its merge
// commit, moves source.main as the forge's word of it would.
func TestAMergeCaughtUpAsAnAdoptedWalkMovesSourceMain(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr(i1726Repo, 470, i1726Head1, "fix/a", "modules/app/a.go"))
w.h.PullMerged(i888MergedAt(i1726Repo, 470, i1726Head1, i1726Merge1, w.now.Add(-time.Hour)))
w.settleAll()
if w.source.main == nil || w.source.main.Heads[i1726Repo] != i1726Merge1 {
t.Fatalf("the heard merge did not move source.main: %+v", w.source.main)
}
// #471's pull.merged is lost; the controller walks its merge commit and the gate adopts the walk.
walk := aWalk("plan-471", i1726Repo, i1726Merge2, false)
walk.Created = w.now.Add(-time.Minute)
walk.State = walkRolling
w.ctl.put(walk)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge2 {
t.Fatalf("source.main holds %s at %s after the walk of %s was adopted; wanted the adopted merge", i1726Repo, got, i1726Merge2)
}
}
// 1726c/AC2: a merge already marked in the source without being written (as the gate before this fix left #471) is written
// when the gate next holds the seat; one older than the head source.main holds never moves it back.
func TestHoldingTheSeatWritesTheNewestMergeOfEachRepositoryNotYetInSourceMain(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr(i1726Repo, 470, i1726Head1, "fix/a", "modules/app/a.go"))
w.h.PullMerged(i888MergedAt(i1726Repo, 470, i1726Head1, i1726Merge1, w.now.Add(-time.Hour)))
w.settleAll()
walk := aWalk("plan-471", i1726Repo, i1726Merge2, false)
walk.Created = w.now.Add(-time.Minute)
walk.State = walkRolling
w.ctl.put(walk)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
// What the gate before this fix left: the adopted merge marked in the source, source.main still at the merge before.
if d := w.delivery(IDOf(i1726Repo, i1726Merge2)); !d.InSource {
t.Fatalf("the adopted merge is not marked in the source: %+v", d)
}
w.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1, "novox/other": "b0"}}
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge1 {
t.Fatalf("source.main moved to %s before the gate held the seat again", got)
}
w.h.SourceCatchUp()
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge2 || w.source.main.Heads["novox/other"] != "b0" {
t.Fatalf("source.main is %+v after the catch-up; wanted %s at %s and the other kept", w.source.main.Heads, i1726Repo, i1726Merge2)
}
n := len(w.source.moves)
w.h.SourceCatchUp()
w.settleAll()
if len(w.source.moves) != n {
t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves))
}
o := newWorld(t)
o.source.main = &sdk.Source{V: sdk.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}}
o.h.PullUpdated(pr(i1726Repo, 471, "cccc000000000000000000000000000000000001", "fix/b", "modules/app/b.go"))
o.h.PullMerged(i888MergedAt(i1726Repo, 471, "cccc000000000000000000000000000000000001", i1726Merge2, o.now.Add(-time.Minute)))
o.h.PullMerged(i888MergedAt(i1726Repo, 470, i1726Head1, i1726Merge1, o.now.Add(-time.Hour)))
o.settleAll()
o.h.SourceCatchUp()
o.settleAll()
if got := o.source.main.Heads[i1726Repo]; got != i1726Merge2 {
t.Fatalf("the catch-up moved source.main back to %s", got)
}
}
// seatUnknown is a seat whose occupants cannot be read yet.
type seatUnknown struct{}
func (seatUnknown) Occupants() ([]string, error) {
return nil, errors.New("the watches have not delivered yet")
}
// seatTaken is a seat today's module still stands on.
type seatTaken struct{}
func (seatTaken) Occupants() ([]string, error) { return []string{"assigned on elsewhere"}, nil }
// 1726c/AC3: an event that reaches the gate before it can tell whether it holds the seat is offered again, never
// acknowledged unread; one that reaches a gate whose seat another holds is acknowledged and left, as before.
func TestAnEventBeforeTheGateKnowsWhetherItHoldsTheSeatIsOfferedAgain(t *testing.T) {
w := newWorld(t)
w.h.Seat = seatUnknown{}
_ = w.h.SeatChanged()
ev := stdio.Envelope{Key: "gitea.pull.merged", Body: mustJSON(i888MergedAt(i1726Repo, 471, i1726Head1, i1726Merge2, w.now))}
if err := handle(w.h, ev); err == nil {
t.Fatal("an event heard before the gate could tell whether it holds the seat was taken: acknowledged unread")
}
w.h.Seat = seatTaken{}
_ = w.h.SeatChanged()
if err := handle(w.h, ev); err != nil {
t.Fatalf("an event heard while today's module holds the seat was offered again: %v", err)
}
}
// 1726c/AC4 (review round 1): an adopted merge that later follows a newer walk keeps the date of the walk it was adopted
// from, so a merge heard from the forge after it stays in source.main through a catch-up.
func TestAnAdoptedMergeFollowingANewerWalkNeverMovesSourceMainBack(t *testing.T) {
w := newWorld(t)
walk := aWalk("plan-471", i1726Repo, i1726Merge2, false)
walk.Created = w.now.Add(-10 * time.Minute)
walk.State = walkRolling
w.ctl.put(walk)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge2 {
t.Fatalf("the adopted merge did not move source.main: %s", got)
}
const later = "eeee000000000000000000000000000000000004"
w.h.PullUpdated(pr(i1726Repo, 473, "ffff000000000000000000000000000000000001", "fix/c", "modules/app/c.go"))
w.h.PullMerged(i888MergedAt(i1726Repo, 473, "ffff000000000000000000000000000000000001", later, w.now.Add(-5*time.Minute)))
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != later {
t.Fatalf("the later forge merge did not move source.main: %s", got)
}
again := aWalk("plan-471-again", i1726Repo, i1726Merge2, false)
again.Created = w.now.Add(-time.Minute)
again.State = walkRolling
w.h.mu.Lock()
w.h.linkWalk(w.h.deliveries[IDOf(i1726Repo, i1726Merge2)], again, i1726Merge2)
w.h.mu.Unlock()
w.h.SourceCatchUp()
w.settleAll()
if got := w.source.main.Heads[i1726Repo]; got != later {
t.Fatalf("the catch-up moved source.main back to the adopted merge %s after it followed a newer walk", got)
}
}
@@ -147,6 +147,7 @@ func run(h *Holder, l *listening, occupancy *busOccupancy) {
func readOnHolding(h *Holder) {
h.wantForge()
h.hearAsks()
h.SourceCatchUp() // a merge missed or marked unwritten meanwhile reaches source.main, and so the checker (issue 1726)
h.Poke()
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := h.Reconcile()
@@ -162,6 +163,11 @@ func readOnHolding(h *Holder) {
// handle takes one event, then asks for a look at every step the event makes possible (Work, React). An event that cannot be read is
// said and acknowledged: read again it would fail again, and the controller's walks are read back by comparison anyway.
func handle(h *Holder, e stdio.Envelope) error {
if h.Undecided() {
// Not known yet whether this gate holds the seat (starting, or carrying today's state): offered again in a few
// seconds, so an event heard at a restart is not lost (issue 1726).
return fmt.Errorf("%s: the gate cannot tell yet whether it holds the seat mesh-delivery; offered again", e.Key)
}
if !h.Holding() {
return nil // not the holder of the seat: the event is acknowledged and taken by no one (seat.go)
}
@@ -433,10 +433,20 @@ func gateMerges(d *Delivery) bool {
// and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than
// a merge of its repository already heard, is never written: source.main never moves backwards. Under the lock; the writing
// happens outside it.
//
// A merge caught up after a gap (issue 1726) is settled the same way: the controller's walk of a merge commit whose
// pull.merged the gate never heard is adopted with the walk's time as its merge's (adopt, mergedWhen), and once each time the
// gate comes to hold the seat (SourceCatchUp) the newest merge of each repository it holds is weighed again though it is
// marked in the source, so a merge marked there and never written reaches source.main, and with it the checker.
func (h *Holder) settleForgeMerges() []func() {
var out []func()
again := map[*Delivery]bool{}
if h.catchUp {
h.catchUp = false
again = h.newestInSource()
}
for _, d := range h.deliveries {
if d.MergedAs == "" || d.InSource || gateMerges(d) {
if d.MergedAs == "" || (d.InSource && !again[d]) || gateMerges(d) {
continue
}
if d.Base != MergeBranch || d.History != "" || h.newerMergeOf(d) {
@@ -444,7 +454,7 @@ func (h *Holder) settleForgeMerges() []func() {
h.keep(d)
continue
}
id, repository, base, commit, at, group := d.ID, d.Repository, d.Base, d.MergedAs, d.MergedAt, firstOf(d.Branch, d.Repository)
id, repository, base, commit, at, group := d.ID, d.Repository, d.Base, d.MergedAs, mergedWhen(d), firstOf(d.Branch, d.Repository)
out = append(out, func() {
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
next := sdk.Source{Group: group, Heads: map[string]string{repository: commit}}
@@ -485,7 +495,7 @@ func (h *Holder) settleForgeMerges() []func() {
// (Ready.MergeCommit, Ready.Merged), which comes first for a merge the gate handed. Empty when it is not merged.
func mergeOf(d *Delivery) (string, time.Time) {
if d.MergedAs != "" {
return d.MergedAs, d.MergedAt
return d.MergedAs, mergedWhen(d)
}
if d.Ready != nil && d.Ready.Merged != nil && d.Ready.MergeCommit != "" {
return d.Ready.MergeCommit, *d.Ready.Merged
@@ -497,7 +507,7 @@ func mergeOf(d *Delivery) (string, time.Time) {
func (h *Holder) newerMergeOf(d *Delivery) bool {
for _, o := range h.deliveries {
if commit, at := mergeOf(o); o != d && o.Repository == d.Repository && o.Base == d.Base && commit != "" &&
!sameCommit(commit, d.MergedAs) && at.After(d.MergedAt) {
!sameCommit(commit, d.MergedAs) && at.After(mergedWhen(d)) {
return true
}
}
@@ -518,3 +528,44 @@ func (h *Holder) olderHead(repository, base, head string, at time.Time) bool {
}
return at.After(h.Now().Add(-keptFinal))
}
// mergedWhen is when a merged delivery's merge landed: its merge time (the forge's, or for a merge adopted from the
// controller's walk the walk's creation, set once in adopt), else when the gate made the delivery. A zero time made every
// other merge of the repository look newer, so an adopted merge never moved source.main (issue 1726).
func mergedWhen(d *Delivery) time.Time {
if !d.MergedAt.IsZero() {
return d.MergedAt
}
return d.Created
}
// SourceCatchUp asks the next look to weigh again, once, the newest merge into the trunk of each repository though it is
// marked in the source (issue 1726): what the gate missed while it did not hold the seat, or marked without writing, reaches
// source.main. The write never moves a repository back (olderHead), and one already there is not written again.
func (h *Holder) SourceCatchUp() {
h.mu.Lock()
h.catchUp = true
h.mu.Unlock()
h.Poke()
}
// newestInSource is, for each repository, its newest merge into the trunk heard from the forge or caught up from a walk,
// marked in the source and not history. Under the lock.
func (h *Holder) newestInSource() map[*Delivery]bool {
newest := map[string]*Delivery{}
for _, d := range h.deliveries {
if d.MergedAs == "" || !d.InSource || gateMerges(d) || d.Base != MergeBranch || d.History != "" {
continue
}
if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) {
newest[d.Repository] = d
}
}
out := map[*Delivery]bool{}
for _, d := range newest {
if !h.newerMergeOf(d) {
out[d] = true
}
}
return out
}
@@ -105,6 +105,11 @@ func (h *Holder) SeatChanged() error {
// Holding is whether the gate acts. A holder with no Seat to ask always holds (the tests'; main sets one).
func (h *Holder) Holding() bool { return h.Seat == nil || h.seatHeld.Load() }
// Undecided is whether the gate cannot tell yet whether it holds the seat: it does not hold it, and no reason it waits was
// found (the watches have not delivered, or today's state is being carried). An event heard then is offered again, never
// acknowledged unread (issue 1726: a merge announced at a restart was lost so, and source.main never moved).
func (h *Holder) Undecided() bool { return !h.Holding() && h.SeatWhy() == "" }
// setSeatWhy keeps why the gate waits and says whether it changed.
func (h *Holder) setSeatWhy(why string) bool {
h.seatMu.Lock()