From 1332f8b5234b593568cd87874442b4086a019ea1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 19:38:50 +0100 Subject: [PATCH 01/10] Ask git ls-tree for nested named paths in rounds: given go and go/seats/x together it lists go's children, so mesh-contracts' preview failed (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/mesh-build-go/buildstep/trees.go | 53 +++++++++++++++---- .../buildstep/trees_integration_test.go | 12 +++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/modules/mesh-build-go/buildstep/trees.go b/modules/mesh-build-go/buildstep/trees.go index 0371f1621..fb41c758b 100644 --- a/modules/mesh-build-go/buildstep/trees.go +++ b/modules/mesh-build-go/buildstep/trees.go @@ -82,18 +82,20 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, for i, p := range paths { full[i] = path.Join(mdir, p) } - r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...) - if r.Status != 0 || r.Err != "" { - return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr)) - } objects := map[string]string{} - for _, l := range strings.Split(r.Stdout, "\x00") { - meta, name, ok := strings.Cut(l, "\t") - fields := strings.Fields(meta) - if !ok || len(fields) != 3 { - continue + for _, round := range unnested(full) { + r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, round...)...) + if r.Status != 0 || r.Err != "" { + return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr)) + } + for _, l := range strings.Split(r.Stdout, "\x00") { + meta, name, ok := strings.Cut(l, "\t") + fields := strings.Fields(meta) + if !ok || len(fields) != 3 { + continue + } + objects[name] = fields[2] } - objects[name] = fields[2] } mt.Paths = map[string]string{} for i, p := range paths { @@ -106,4 +108,35 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, return nil } +// unnested splits paths into rounds of one git ls-tree each, none of whose paths lies inside another of its round: given +// "go" and "go/seats/x" together, git ls-tree descends into go and lists its children instead of go itself, so the +// object of go is missing from its answer (issue 1966: mesh-contracts' manifest names both). Usually one round; the +// order of paths is kept within a round. +func unnested(paths []string) [][]string { + var rounds [][]string + for _, p := range paths { + placed := false + for i, round := range rounds { + if !nestsWithAny(p, round) { + rounds[i] = append(round, p) + placed = true + break + } + } + if !placed { + rounds = append(rounds, []string{p}) + } + } + return rounds +} + +func nestsWithAny(p string, round []string) bool { + for _, q := range round { + if p == q || strings.HasPrefix(p, q+"/") || strings.HasPrefix(q, p+"/") || p == "." || q == "." { + return true + } + } + return false +} + func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } diff --git a/modules/mesh-build-go/buildstep/trees_integration_test.go b/modules/mesh-build-go/buildstep/trees_integration_test.go index 65df0519f..6bf462db6 100644 --- a/modules/mesh-build-go/buildstep/trees_integration_test.go +++ b/modules/mesh-build-go/buildstep/trees_integration_test.go @@ -62,4 +62,16 @@ func TestAGoOnlyCommitLeavesTheNamedObjectsEqualOnRealGit(t *testing.T) { if src["."].Paths["src"] != realGit(t, repo, "rev-parse", "HEAD:src") { t.Fatalf("the object of src is %s, git says %s", src["."].Paths["src"], realGit(t, repo, "rev-parse", "HEAD:src")) } + + // A manifest naming a path and a path inside it (mesh-contracts' "go" and "go/seats/meshvault"; issue 1966): each + // gets its own object, not git ls-tree's listing of the outer one's children. + writeFile(t, repo, "nested/module.json", `{"module":"nested","version":"1","build":{"artifacts":[`+ + `{"name":"go","kind":"package","language":"go","paths":["go"]},`+ + `{"name":"go-seat","kind":"package","language":"go","paths":["go/seats/a"]}]}}`) + writeFile(t, repo, "nested/go/go.mod", "module x\n") + writeFile(t, repo, "nested/go/seats/a/a.go", "package a\n") + nested := trees(commit("nested"))["nested"] + if nested.Paths["go"] != realGit(t, repo, "rev-parse", "HEAD:nested/go") || nested.Paths["go/seats/a"] != realGit(t, repo, "rev-parse", "HEAD:nested/go/seats/a") { + t.Fatalf("a path and a path inside it: %v", nested.Paths) + } } From da2aa918322145198eb6def10ae36133adae7f71 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 19:45:55 +0100 Subject: [PATCH 02/10] Keep Go's indented continuation in a step's cause: the composer's reason ended at "requires" and hid the module that could not be found (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../cmd/mesh-build-go/candidate.go | 5 +++-- .../mesh-build-go/cmd/mesh-build-go/fetch.go | 22 +++++++++++++++++-- .../cmd/mesh-build-go/fetch_test.go | 5 +++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go index 4379d3ca4..27f43d778 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go @@ -97,9 +97,10 @@ func (m *Module) candidate(ctx context.Context, workspace string) (string, error // compileError is the first line of the compiler's output that says an error: not the `# package` heading go build // prints above a package's errors, nor Go's progress. func compileError(out string) string { - for _, l := range strings.Split(out, "\n") { + lines := strings.Split(out, "\n") + for i, l := range lines { if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") && !strings.HasPrefix(l, "go: downloading ") { - return l + return withContinuation(l, lines[i+1:]) } } return firstLine(out) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 8644edd9a..8f66a1a9b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -351,15 +351,33 @@ func (m *Module) now() time.Time { return m.clock() } // causeLine is the first line of a go command's output that says something other than Go's own progress: `go: // downloading ...` comes first when Go fetches a module, and `# ` heads a compile error of go build or go vet // (issue 1703: the reason said only the package), and the cause of a failure on a later line. +// +// A cause Go writes over several lines keeps its indented continuation lines, joined by a space: a module graph error +// is "X requires" with the module that fails, and why, on the next line (issue 1966: the composer's reason ended at +// "requires" and hid "golang.org/x/tools@v0.42.0: module lookup disabled"). func causeLine(s string) string { - for _, l := range strings.Split(s, "\n") { + lines := strings.Split(s, "\n") + for i, l := range lines { if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") && !strings.HasPrefix(l, "# ") { - return l + return withContinuation(l, lines[i+1:]) } } return firstLine(s) } +// withContinuation is first with each following line Go indented under it, trimmed and joined by a space. +func withContinuation(first string, rest []string) string { + for _, l := range rest { + if !strings.HasPrefix(l, "\t") && !strings.HasPrefix(l, " ") { + break + } + if l = strings.TrimSpace(l); l != "" { + first += " " + l + } + } + return first +} + func firstLine(s string) string { for _, l := range strings.Split(s, "\n") { if l = strings.TrimSpace(l); l != "" { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go index e86e15591..81d7c5d57 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go @@ -309,9 +309,14 @@ func TestCauseLineSkipsProgressAndThePackageHeading(t *testing.T) { "go: downloading x v1\n# git.example/m/cmd\n./main.go:3:2: undefined: y\n": "./main.go:3:2: undefined: y", "\ngo: example.com/x@v1: reading: 404\n": "go: example.com/x@v1: reading: 404", "go: downloading x v1\n": "go: downloading x v1", + // issue 1966: a module graph error goes on, indented, on the next line; a later error is not taken in. + "a.go:3:8: x@v1 requires\n\ty@v2: module lookup disabled by GOPROXY=off\nb.go:1:1: other\n": "a.go:3:8: x@v1 requires y@v2: module lookup disabled by GOPROXY=off", } { if got := causeLine(in); got != want { t.Errorf("causeLine(%q) = %q, want %q", in, got, want) } + if got := compileError(in); got != want { + t.Errorf("compileError(%q) = %q, want %q", in, got, want) + } } } From 796fb69d17f0f288b7123f480f23e417ad599144 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 22:47:56 +0100 Subject: [PATCH 03/10] Gate ignores a group's end on FORGE before decoding; keeper row for take-in-waits (issue 1585, ADR 0633) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-conditions/keeper_test.go | 11 +++++++++++ .../cmd/mesh-delivery-gate/holder_test.go | 5 +++++ .../cmd/mesh-delivery-gate/merges.go | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index c2d6b3bb2..c36bfb218 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -240,6 +240,14 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test if err := r.k.Finding(watchman, key, raw, false); err != nil { t.Fatal(err) } + // take-in-waits (issue 1585) is an observer's kind with no row in Homes: its finding is raised and EndCarried leaves it (T26). + takeKey, takeRaw := finding(t, watchman, health.ScopeMachine, "g14", "take-in-waits", "take-in", health.Warning, r.now) + if err := r.k.Finding(watchman, takeKey, takeRaw, false); err != nil { + t.Fatal(err) + } + if _, open := r.open(takeKey); !open { + t.Fatal("a take-in-waits finding raised no condition") + } r.said = nil // Carried while the controller ran: two of kinds that ended with it, still open in conditions. for _, c := range []health.Condition{ @@ -257,6 +265,9 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test if _, open := r.open(key); !open { t.Error("a condition a finding holds was cleared") } + if _, open := r.open(takeKey); !open { + t.Error("EndCarried cleared a take-in-waits condition") + } if n, err := r.k.EndCarried(); err != nil || n != 0 || r.changes() != "" { t.Errorf("a second EndCarried cleared %d (%v)", n, err) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index 0c5d84dde..fd87a0172 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -251,6 +251,11 @@ func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) { if len(w.h.refusals) != 0 { t.Errorf("the gate refused a merged message with no trees: %v", w.h.refusals) } + // A group's end is the indexer's: the gate ignores it before decoding, and says no refusal (1585/T25). + w.h.Forged("mesh-delivery-forge-bridge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`)) + if len(w.h.refusals) != 0 { + t.Errorf("the gate refused a group's end: %v", w.h.refusals) + } } // ADR 0419 §2, issue 978: a description's `after:` line is not read, and says so once on its pull request. The diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go index d2634300b..f8628af46 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -312,8 +312,17 @@ func (h *Holder) mergeValue(name, key string, approved map[string]string, target return m, len(m.Pulls) > 0 && state == sdk.MergeMerged } +// forgeGroupEnd ends the subject of a group end on FORGE: the four-part form of a pull request's message has no such tail. +const forgeGroupEnd = ".forge.group.merged" + // Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else. +// +// The end of a group's merges (`forge.group.merged`, a body this gate has no use for: the gate wrote the merges itself) is +// the indexer's, and is ignored before it is decoded, so no refusal is said for it (issue 1585, ADR 0633 clause 4). func (h *Holder) Forged(event string, body []byte) { + if strings.HasSuffix(event, forgeGroupEnd) { + return + } f, err := sdk.DecodeForge(body) if err != nil { h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err)) From 8377f8a878c7a68f500d1189b6d8ee15dc4cba33 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 23:14:17 +0100 Subject: [PATCH 04/10] operator-channel.asks: key, state, since and bound narrow its answer, which says when it cut it short (issue 595) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/messenger/cmd/messenger/asks.go | 73 ++++++++++++++++++++ modules/messenger/cmd/messenger/held_test.go | 53 ++++++++++++++ modules/messenger/cmd/messenger/main.go | 20 +++++- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index bc8dfc344..86ac84332 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -1416,3 +1416,76 @@ func (h *Holder) AsksNow() []AskView { }) return out } + +// AsksQuery narrows what the tool `operator-channel.asks` answers (issue 595): Key one ask, State open (open and held) or +// ended, Since the asks opened at or after that time, Bound the most bytes of the answer's asks. The zero query keeps the +// whole answer, as before. +type AsksQuery struct { + Key, State string + Since time.Time + Bound int +} + +// AsksAnswer is the answer of a narrowed query: the asks kept, how many the filters matched, and, when the bound cut the +// answer short, that it did and how. +type AsksAnswer struct { + Asks []AskView `json:"asks"` + Matched int `json:"matched"` + Cut string `json:"cut,omitempty"` +} + +// AsksQueryOf reads the arguments of the tool; an empty set is the zero query. +func AsksQueryOf(a map[string]any) (AsksQuery, error) { + q := AsksQuery{Key: strArg(a, "key"), State: strArg(a, "state")} + if q.State != "" && q.State != "open" && q.State != "ended" { + return q, errorf("state is open or ended, not %q", q.State) + } + if since := strArg(a, "since"); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err != nil { + return q, errorf("since is a time like 2026-11-04T22:00:00Z: %v", err) + } + q.Since = t + } + if v, ok := a["bound"].(float64); ok { + if v < 1 { + return q, errorf("bound is the most bytes of the answer, at least 1") + } + q.Bound = int(v) + } + return q, nil +} + +// Narrow keeps of the views (open first, newest first) the asks the query names, and cuts the answer at its byte bound: the +// asks that fit, in order, and a said cut when one more did not. +func (q AsksQuery) Narrow(all []AskView) AsksAnswer { + var out AsksAnswer + out.Asks = []AskView{} + var kept []AskView + for _, v := range all { + open := v.State == StateOpen || v.State == StateHeld + switch { + case q.Key != "" && v.Key != q.Key, + q.State == "open" && !open, + q.State == "ended" && open, + !q.Since.IsZero() && v.Opened < q.Since.UTC().Format(time.RFC3339): + continue + } + kept = append(kept, v) + } + out.Matched = len(kept) + used := 0 + for i, v := range kept { + if q.Bound > 0 { + raw, _ := json.Marshal(v) + if used+len(raw)+1 > q.Bound { + out.Cut = fmt.Sprintf("the answer is cut at its bound of %d bytes: %d of %d asks are shown, the %d after them are not "+ + "(narrow with key, state or since, or raise bound)", q.Bound, i, len(kept), len(kept)-i) + break + } + used += len(raw) + 1 + } + out.Asks = append(out.Asks, v) + } + return out +} diff --git a/modules/messenger/cmd/messenger/held_test.go b/modules/messenger/cmd/messenger/held_test.go index 1efae214f..2d6c74c1e 100644 --- a/modules/messenger/cmd/messenger/held_test.go +++ b/modules/messenger/cmd/messenger/held_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "reflect" "strings" "testing" "time" @@ -140,3 +141,55 @@ func TestAHeldAskNamesEveryKindAndWhyWhenNoChannelIsReady(t *testing.T) { t.Fatalf("%q", why) } } + +// Issue 595: operator-channel.asks answers one ask, the open or the ended, those opened from a time, and no more bytes than a +// bound, said when it cut the answer short; with no argument it keeps the whole answer. +func TestTheAsksVerbNarrowsItsAnswer(t *testing.T) { + view := func(key, state, opened string) AskView { + return AskView{Key: key, Asker: "mesh-delivery", Headline: "Approve " + key, State: state, Opened: opened} + } + all := []AskView{view("a.3", StateOpen, "2026-11-04T10:00:00Z"), view("a.2", StateHeld, "2026-11-03T10:00:00Z"), + view("a.1", "ended", "2026-11-02T10:00:00Z"), view("a.0", "ended", "2026-11-01T10:00:00Z")} + one, _ := json.Marshal(all[0]) + for _, tc := range []struct { + name string + args map[string]any + want []string + matched int + cut bool + err bool + }{ + {"no argument is the zero query", map[string]any{}, nil, 0, false, false}, + {"key", map[string]any{"key": "a.1"}, []string{"a.1"}, 1, false, false}, + {"state open takes open and held", map[string]any{"state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"state ended", map[string]any{"state": "ended"}, []string{"a.1", "a.0"}, 2, false, false}, + {"since", map[string]any{"since": "2026-11-02T12:00:00Z"}, []string{"a.3", "a.2"}, 2, false, false}, + {"bound cuts and says so", map[string]any{"bound": float64(2*len(one) + 10), "state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"a bound under the asks cuts", map[string]any{"bound": float64(len(one) + 5)}, []string{"a.3"}, 4, true, false}, + {"a state that is neither", map[string]any{"state": "done"}, nil, 0, false, true}, + {"a time that is not one", map[string]any{"since": "yesterday"}, nil, 0, false, true}, + } { + q, err := AsksQueryOf(tc.args) + if (err != nil) != tc.err { + t.Errorf("%s: err = %v", tc.name, err) + continue + } + if tc.err { + continue + } + if tc.want == nil { + if q != (AsksQuery{}) { + t.Errorf("%s: %+v is not the zero query, which keeps today's answer", tc.name, q) + } + continue + } + got := q.Narrow(all) + var keys []string + for _, v := range got.Asks { + keys = append(keys, v.Key) + } + if !reflect.DeepEqual(keys, tc.want) || got.Matched != tc.matched || (got.Cut != "") != tc.cut { + t.Errorf("%s: asks %v matched %d cut %q, want %v, %d, cut %v", tc.name, keys, got.Matched, got.Cut, tc.want, tc.matched, tc.cut) + } + } +} diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index f237289a1..d1346ef97 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -734,8 +734,24 @@ func tools(h *Holder, l *listening) []stdio.Tool { {Name: "operator-channel.asks", Description: "The asks this router holds (novox/hq ADR 0259): open (or held, with since when and why no channel can carry it yet) first, then the last month's — each " + "one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " + - "ended (the warrant: who chose what, through which channel), and whether its asker was told.", - Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }}, + "ended (the warrant: who chose what, through which channel), and whether its asker was told. With no argument every " + + "ask, which is large; filter it: key for one ask, state open or ended, since (a time, RFC 3339) for the asks opened " + + "from then, and bound, the most bytes of the answer, which says when it cut it short.", + Input: map[string]any{ + "key": str("one ask, by its key (asker.id); none for every ask"), + "state": str("open (open and held) or ended; none for both"), + "since": str("only the asks opened at or after this time, RFC 3339"), + "bound": map[string]any{"type": "integer", "description": "the most bytes of the asks in the answer; said when it cuts the answer short"}}, + Run: func(a map[string]any) (any, error) { + q, err := AsksQueryOf(a) + if err != nil { + return nil, err + } + if q == (AsksQuery{}) { + return h.AsksNow(), nil + } + return q.Narrow(h.AsksNow()), nil + }}, {Name: "messenger_identities", Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + "service shows, when it was linked, and from when it can approve (an hour after linking).", From ebe1248f0bf54423bcab2d19ef030c8610ce5b26 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 00:06:59 +0100 Subject: [PATCH 05/10] Gate: read the forge bridge's messages from its own package forgemsg, and pin mesh-sdk at main (issue 1585, ADR 0633) Forge, its kinds and the group's end moved out of mesh-sdk into the forge bridge's own package (mesh-delivery modules/mesh-delivery-forge-bridge/forgemsg, the operator, 2026-11-04: logic one module owns lives with it). The gate imports it at the mesh-delivery head 4e4601a1 that holds the move, and the suffix it passes by before decoding is the package's own ForgeGroupEndEvent, not a constant of its own. mesh-sdk is pinned at 5a38a12, origin/main. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-gate/bridge_test.go | 15 ++++++++------- .../cmd/mesh-delivery-gate/bus_test.go | 13 +++++++------ .../cmd/mesh-delivery-gate/holder_test.go | 6 +++--- .../cmd/mesh-delivery-gate/issue1275_test.go | 3 ++- .../cmd/mesh-delivery-gate/issue1348_test.go | 9 +++++---- .../cmd/mesh-delivery-gate/merges.go | 18 ++++++++++-------- modules/mesh-delivery-gate/go.mod | 3 ++- modules/mesh-delivery-gate/go.sum | 6 ++++-- 8 files changed, 41 insertions(+), 32 deletions(-) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go index 1749fe5a1..e27803c32 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -24,7 +25,7 @@ type fakeMerges struct { manual bool failed error // said are the messages the bridge wrote on FORGE, in order. - said []sdk.Forge + said []forgemsg.Forge } func newFakeMerges(f *fakeForge) *fakeMerges { @@ -76,11 +77,11 @@ func (m *fakeMerges) Put(group string, v sdk.Merge) error { err = f.refuse } f.mu.Unlock() - msg := sdk.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head} + msg := forgemsg.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head} if err != nil { - msg.Kind, msg.Act, msg.Words = sdk.ForgeRefused, "merge", err.Error() + msg.Kind, msg.Act, msg.Words = forgemsg.ForgeRefused, "merge", err.Error() } else { - msg.Kind, msg.Merge, msg.Root = sdk.ForgeMerged, strings.Repeat("c", 40), "root" + msg.Kind, msg.Merge, msg.Root = forgemsg.ForgeMerged, strings.Repeat("c", 40), "root" msg.Trees = map[string]string{"app": "tree"} } m.say(msg) @@ -89,12 +90,12 @@ func (m *fakeMerges) Put(group string, v sdk.Merge) error { } // say writes a message on FORGE and has the gate hear it, as the runtime does. -func (m *fakeMerges) say(msg sdk.Forge) { - body, err := sdk.EncodeForge(msg) +func (m *fakeMerges) say(msg forgemsg.Forge) { + body, err := forgemsg.EncodeForge(msg) if err != nil { panic(err) } - event, err := sdk.ForgeEvent(msg.Repository, msg.Kind) + event, err := forgemsg.ForgeEvent(msg.Repository, msg.Kind) if err != nil { panic(err) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index d4f8777a9..44237dcb3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" @@ -582,7 +583,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB t.Fatalf("the gate asked the forge to merge %d times: the bridge merges", n) } // The bridge says it merged; the mesh's source then holds the merge, in one write. - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40), + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"app": "t"}}) // The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged. b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 7, head, strings.Repeat("b", 40))) @@ -677,13 +678,13 @@ func (b *testBus) answer(t *testing.T, key, option string) time.Time { } // forgeSays is the forge bridge's message on FORGE, as its stream carries it. -func (b *testBus) forgeSays(t *testing.T, f sdk.Forge) { +func (b *testBus) forgeSays(t *testing.T, f forgemsg.Forge) { t.Helper() - raw, err := sdk.EncodeForge(f) + raw, err := forgemsg.EncodeForge(f) if err != nil { t.Fatal(err) } - event, err := sdk.ForgeEvent(f.Repository, f.Kind) + event, err := forgemsg.ForgeEvent(f.Repository, f.Kind) if err != nil { t.Fatal(err) } @@ -742,7 +743,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls) } one := first.Pulls[0] - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head, + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) var both sdk.Merge rt.until("the second member to be handed", eventWithin, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 }) @@ -750,7 +751,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe t.Fatalf("the second merge handed is %+v", both.Pulls) } two := both.Pulls[1] - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head, + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head, Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}}) rt.until("source.main to hold both merges", eventWithin, func() bool { e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index fd87a0172..7bea50f41 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - sdk "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" ) // The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*, cut by ADR 0539 §17): a pull request's head @@ -241,9 +241,9 @@ func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) { t.Fatalf("source.main is %+v, wanted both merges", w.source.main) } // A merge that leaves no module.json carries no trees (issue 1367): the gate reads it, and refuses nothing. - msg := sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/a", Number: 9, Head: strings.Repeat("e", 40), + msg := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/a", Number: 9, Head: strings.Repeat("e", 40), Merge: strings.Repeat("f", 40), Root: "r"} - body, err := sdk.EncodeForge(msg) + body, err := forgemsg.EncodeForge(msg) if err != nil { t.Fatalf("a merged message with no trees is refused: %v", err) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go index 73d88678f..b6143c64c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -765,6 +766,6 @@ func allGreenFor(w *world, ms ...member) { // bridgeMerged is the forge bridge's word that it merged a member. func bridgeMerged(w *world, repository string, number int, head, merge string) { - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root", + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root", Trees: map[string]string{"app": "tree"}}) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go index 98a9cac5b..aebe95ed6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -100,7 +101,7 @@ func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testi if len(w.merges.puts) != 1 { t.Fatalf("the next member was handed before the bridge said the first merged: %d merges written", len(w.merges.puts)) } - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) w.settleAll() if len(w.merges.puts) != 2 { t.Fatalf("the second member was not handed after the first merged: %d merges written", len(w.merges.puts)) @@ -115,7 +116,7 @@ func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testi // AC2: the bridge's refused hands no member, and the group is said urgent, naming the member and the forge's words. func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { w := answeredFirst(t) - w.merges.say(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"}) w.later(time30s) w.settleAll() @@ -137,7 +138,7 @@ func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { // the group, even after the first member merged. func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) { w := answeredFirst(t) - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile w.settleAll() if len(w.merges.puts) != 1 { @@ -193,7 +194,7 @@ func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing. w := answeredFirst(t) d := w.h.deliveries[IDOf(gCat, gHk)] refuse := func(took string) { - w.h.ForgeRefused(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", + w.h.ForgeRefused(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", Words: "novox/mesh-catalog#2 is not merged: its verdict names no trunk commit it was judged against, and main is now f70a99dbf485. " + "The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"}) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go index f8628af46..aa5c17af0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" stdio "git.novox.be/novox/mesh-sdk/go" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -312,8 +313,9 @@ func (h *Holder) mergeValue(name, key string, approved map[string]string, target return m, len(m.Pulls) > 0 && state == sdk.MergeMerged } -// forgeGroupEnd ends the subject of a group end on FORGE: the four-part form of a pull request's message has no such tail. -const forgeGroupEnd = ".forge.group.merged" +// forgeGroupEnd ends the subject of a group end on FORGE, the forge bridge's event name: the four-part form of a pull +// request's message has no such tail. +const forgeGroupEnd = "." + forgemsg.ForgeGroupEndEvent // Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else. // @@ -323,21 +325,21 @@ func (h *Holder) Forged(event string, body []byte) { if strings.HasSuffix(event, forgeGroupEnd) { return } - f, err := sdk.DecodeForge(body) + f, err := forgemsg.DecodeForge(body) if err != nil { h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err)) return } switch { - case f.Kind == sdk.ForgeMerged: + case f.Kind == forgemsg.ForgeMerged: h.ForgeMerged(f) - case f.Kind == sdk.ForgeRefused && f.Act == "merge": + case f.Kind == forgemsg.ForgeRefused && f.Act == "merge": h.ForgeRefused(f) } } // memberOfForge is the delivery a forge message is about: at the head it names. -func (h *Holder) memberOfForge(f sdk.Forge) *Delivery { +func (h *Holder) memberOfForge(f forgemsg.Forge) *Delivery { if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number { return d } @@ -346,7 +348,7 @@ func (h *Holder) memberOfForge(f sdk.Forge) *Delivery { // ForgeMerged takes the bridge's word that a pull request the gate handed to it merged: the merge is announced once, // with the ask, its asker the teller and the heads (issue 1025), and the next member's turn comes at the next look. -func (h *Holder) ForgeMerged(f sdk.Forge) { +func (h *Holder) ForgeMerged(f forgemsg.Forge) { h.mu.Lock() defer h.mu.Unlock() d := h.memberOfForge(f) @@ -363,7 +365,7 @@ func (h *Holder) ForgeMerged(f sdk.Forge) { // ForgeRefused takes the bridge's word that the forge refused a merge: nothing after it is merged, and the refusal is said // as a stalled line (ADR 0441 §3, §4). The merge is handed again only at the next event that decides whether it may // merge (mergeAgainNow). -func (h *Holder) ForgeRefused(f sdk.Forge) { +func (h *Holder) ForgeRefused(f forgemsg.Forge) { h.mu.Lock() defer h.mu.Unlock() d := h.memberOfForge(f) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index c0544f393..1f467a4f1 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,8 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index abad29696..6120abbd2 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90 h1:8akSVoPjgngMbC1JIstcUzfKyYs13gDaboDRKC4NQ/A= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 h1:tK7MzxVm/ECNg1Yblhh1ynVEthbZCg2mSPW0wQZC0b0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From d023a25849f373c7e5f5529143241df94fe4f7bc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 00:29:37 +0100 Subject: [PATCH 06/10] Gate and keeper, review round 1: a group's end is told by its whole event, the gate pinned at mesh-delivery 7b7096f3, the keeper's row dropped (issue 1585, ADR 0633) The gate passes by a group's end only when the event is the forge bridge's forge.group.merged whole, so the merged message of a repository named forge/group is read as one; row in the holder test. The keeper's take-in-waits row (T26) is dropped: it showed nothing the keeper does not already hold. The gate pins forgemsg at mesh-delivery 7b7096f3. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-conditions/keeper_test.go | 11 ----------- .../cmd/mesh-delivery-gate/holder_test.go | 6 ++++++ .../cmd/mesh-delivery-gate/merges.go | 8 ++++---- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 ++-- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index c36bfb218..c2d6b3bb2 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -240,14 +240,6 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test if err := r.k.Finding(watchman, key, raw, false); err != nil { t.Fatal(err) } - // take-in-waits (issue 1585) is an observer's kind with no row in Homes: its finding is raised and EndCarried leaves it (T26). - takeKey, takeRaw := finding(t, watchman, health.ScopeMachine, "g14", "take-in-waits", "take-in", health.Warning, r.now) - if err := r.k.Finding(watchman, takeKey, takeRaw, false); err != nil { - t.Fatal(err) - } - if _, open := r.open(takeKey); !open { - t.Fatal("a take-in-waits finding raised no condition") - } r.said = nil // Carried while the controller ran: two of kinds that ended with it, still open in conditions. for _, c := range []health.Condition{ @@ -265,9 +257,6 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test if _, open := r.open(key); !open { t.Error("a condition a finding holds was cleared") } - if _, open := r.open(takeKey); !open { - t.Error("EndCarried cleared a take-in-waits condition") - } if n, err := r.k.EndCarried(); err != nil || n != 0 || r.changes() != "" { t.Errorf("a second EndCarried cleared %d (%v)", n, err) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index 7bea50f41..3de163f4d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -256,6 +256,12 @@ func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) { if len(w.h.refusals) != 0 { t.Errorf("the gate refused a group's end: %v", w.h.refusals) } + // The repository forge/group's merged message ends the same way: it is decoded as a forge message, and this one, + // which is none, is refused. + w.h.Forged("mesh-delivery-forge-bridge.forge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`)) + if len(w.h.refusals) != 1 { + t.Errorf("forge/group's merged message was passed by as a group's end: refusals %v", w.h.refusals) + } } // ADR 0419 §2, issue 978: a description's `after:` line is not read, and says so once on its pull request. The diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go index aa5c17af0..a5212a7bf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -313,16 +313,16 @@ func (h *Holder) mergeValue(name, key string, approved map[string]string, target return m, len(m.Pulls) > 0 && state == sdk.MergeMerged } -// forgeGroupEnd ends the subject of a group end on FORGE, the forge bridge's event name: the four-part form of a pull -// request's message has no such tail. -const forgeGroupEnd = "." + forgemsg.ForgeGroupEndEvent +// forgeGroupEnd is the whole event of a group's end on FORGE, the forge bridge's event name under its module: compared +// exactly, so the merged message of a repository named forge/group, which ends the same way, is read as one. +const forgeGroupEnd = sdk.ForgeBridgeModule + "." + forgemsg.ForgeGroupEndEvent // Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else. // // The end of a group's merges (`forge.group.merged`, a body this gate has no use for: the gate wrote the merges itself) is // the indexer's, and is ignored before it is decoded, so no refusal is said for it (issue 1585, ADR 0633 clause 4). func (h *Holder) Forged(event string, body []byte) { - if strings.HasSuffix(event, forgeGroupEnd) { + if event == forgeGroupEnd { return } f, err := forgemsg.DecodeForge(body) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 1f467a4f1..ee2320886 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index 6120abbd2..eaea0383d 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90 h1:8akSVoPjgngMbC1JIstcUzfKyYs13gDaboDRKC4NQ/A= -git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104230501-4e4601a1eb90/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7 h1:VWgmpV8KOOjq+KFqq0ofyZ52HUHoBfCqP5Z0/vUq++k= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 h1:tK7MzxVm/ECNg1Yblhh1ynVEthbZCg2mSPW0wQZC0b0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= From 5ceb6e9e38a4538e1cf10deae0548ef7e75a98e5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 00:59:56 +0100 Subject: [PATCH 07/10] Gate: pin forgemsg at mesh-delivery c78941e8, the head whose take-in events need no stream (issue 1585) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index ee2320886..c3c093abf 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index eaea0383d..be189a5d7 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7 h1:VWgmpV8KOOjq+KFqq0ofyZ52HUHoBfCqP5Z0/vUq++k= -git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104232856-7b7096f37ea7/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59 h1:pFb+lP0FxS3bF6N3gHKtjzyixI9b1vC/NLViT/lVzRQ= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 h1:tK7MzxVm/ECNg1Yblhh1ynVEthbZCg2mSPW0wQZC0b0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= From b0bdcd02c902a5567904128f9ff0722608c215dd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 09:39:24 +0100 Subject: [PATCH 08/10] nfs-server: the look below a share runs beside the pass, so the status is written every pass (issue 1971) A walk of minutes inside the pass held the status back until the module was found unhealthy and the assigner held the version. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/nfs-server/cmd/nfs-server/machine.go | 6 +- modules/nfs-server/cmd/nfs-server/nfs_test.go | 2 +- modules/nfs-server/cmd/nfs-server/process.go | 3 +- .../nfs-server/cmd/nfs-server/review_test.go | 51 ++++++++++++ modules/nfs-server/cmd/nfs-server/shares.go | 81 ++++++++++++++----- 5 files changed, 119 insertions(+), 24 deletions(-) diff --git a/modules/nfs-server/cmd/nfs-server/machine.go b/modules/nfs-server/cmd/nfs-server/machine.go index fdd948bf4..6025426bb 100644 --- a/modules/nfs-server/cmd/nfs-server/machine.go +++ b/modules/nfs-server/cmd/nfs-server/machine.go @@ -105,8 +105,10 @@ type Machine struct { // ownerOf reads a file's owner from its inode; nil is the inode as the kernel says it. Tests set it // so the folders they make stand for a non-root owner, whoever runs them. ownerOf func(info os.FileInfo) (uid, gid int) - // clean is when a look below each real path last found no backup repository (LookKept). - clean map[string]time.Time + // Beside runs a look below a share beside the pass; nil is a goroutine. Tests run it in place. + Beside func(func()) + // looks is the last look below each real path (LookKept). + looks map[string]*lookBelow cleanMu sync.Mutex // walk is the running pass's context, which ends the look below a share when the process stops. walk context.Context diff --git a/modules/nfs-server/cmd/nfs-server/nfs_test.go b/modules/nfs-server/cmd/nfs-server/nfs_test.go index dbc45970f..b6d7424fd 100644 --- a/modules/nfs-server/cmd/nfs-server/nfs_test.go +++ b/modules/nfs-server/cmd/nfs-server/nfs_test.go @@ -32,7 +32,7 @@ func newFake(t *testing.T) *fake { } func (f *fake) machine() *Machine { - return &Machine{Root: f.root, UID: f.uid, owners: f.owners, ownerOf: asTheLibrarysOwner, Now: func() time.Time { return f.now }, Run: func(_ context.Context, name string, args ...string) Ran { + return &Machine{Root: f.root, UID: f.uid, owners: f.owners, ownerOf: asTheLibrarysOwner, Beside: func(f func()) { f() }, Now: func() time.Time { return f.now }, Run: func(_ context.Context, name string, args ...string) Ran { line := strings.Join(append([]string{name}, args...), " ") f.ran = append(f.ran, line) if (line == "exportfs -ra" || line == "sudo -n exportfs -ra") && f.onExportfs != nil { diff --git a/modules/nfs-server/cmd/nfs-server/process.go b/modules/nfs-server/cmd/nfs-server/process.go index c6c6172ed..39d33ce90 100644 --- a/modules/nfs-server/cmd/nfs-server/process.go +++ b/modules/nfs-server/cmd/nfs-server/process.go @@ -61,9 +61,8 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status { if _, note := m.Backup(); note != "" { s.Warnings = append(s.Warnings, note) } - m.setWalk(ctx) + m.setWalk(ctx) // stays set: a look beside the pass ends when the process does exports, refused, said := m.Judge(c, roster, wants) - m.setWalk(nil) if ctx.Err() != nil { // Stopping: write nothing and reload nothing, so Stopped starts at once. s.Problems = append(s.Problems, "the pass was ended by the process stopping; nothing was written") diff --git a/modules/nfs-server/cmd/nfs-server/review_test.go b/modules/nfs-server/cmd/nfs-server/review_test.go index 86128e7a9..e987bf463 100644 --- a/modules/nfs-server/cmd/nfs-server/review_test.go +++ b/modules/nfs-server/cmd/nfs-server/review_test.go @@ -432,3 +432,54 @@ func TestAHangingCommandStillLetsStoppedRemoveTheFileInTime(t *testing.T) { t.Errorf("the export file stays after an unassignment whose commands hung") } } + +// Issue 1971: the look below a share runs beside the pass. A pass whose walk has not finished still writes +// its status, refuses the share until a look has finished, keeps believing a clean look while it is renewed, +// and a walk ended by the process stopping leaves no result. +func TestTheLookBelowRunsBesideThePassAndNeverHoldsItsStatus(t *testing.T) { + f := newFake(t) + f.mountinfo("/") + f.dir("/srv/clean/films/one") + f.file(ConfigFile, grantsConfig("clean=/srv/clean", "clean=ws:rw")) + f.roster("ws 10.0.0.3") + f.wants(map[string][]Want{"ws": {{Share: "clean", Access: "rw"}}}) + m := f.machine() + var walks []func() + m.Beside = func(w func()) { walks = append(walks, w) } // the walk is still running + now := time.Date(2026, 11, 5, 5, 0, 0, 0, time.UTC) + m.Now = func() time.Time { return now } + + s := m.Pass(context.Background(), nil) + if !s.At.Equal(now) || f.read(StatusFile) == "" || len(walks) != 1 { + t.Fatalf("the pass waited for the walk: at %v, walks %d", s.At, len(walks)) + } + if !strings.Contains(refusedWhy(s, "clean"), "not been looked through yet") { + t.Errorf("a share whose look is unfinished is exported: %+v", s.Refused) + } + m.Pass(context.Background(), &s) + if len(walks) != 1 { + t.Errorf("a second walk started while one runs: %d", len(walks)) + } + walks[0]() + if s = m.Pass(context.Background(), &s); refusedWhy(s, "clean") != "" { + t.Fatalf("the finished look was not used: %+v", s.Refused) + } + + now = now.Add(LookKept) + s = m.Pass(context.Background(), &s) + if len(walks) != 2 || refusedWhy(s, "clean") != "" { + t.Errorf("the renewal must run beside the pass and the clean look stand: walks %d, %+v", len(walks), s.Refused) + } + ctx, cancel := context.WithCancel(context.Background()) + m.Pass(ctx, &s) + cancel() + f.restic("/srv/clean/films/one/backup") + walks[1]() + if s = m.Pass(context.Background(), &s); refusedWhy(s, "clean") != "" { + t.Errorf("a walk ended by the stop left a result: %+v", s.Refused) + } + walks[len(walks)-1]() + if s = m.Pass(context.Background(), &s); !strings.Contains(refusedWhy(s, "clean"), "/srv/clean/films/one/backup") { + t.Errorf("the repository was not found by the next look: %+v", s.Refused) + } +} diff --git a/modules/nfs-server/cmd/nfs-server/shares.go b/modules/nfs-server/cmd/nfs-server/shares.go index 073ee0cbc..ca81ddf9e 100644 --- a/modules/nfs-server/cmd/nfs-server/shares.go +++ b/modules/nfs-server/cmd/nfs-server/shares.go @@ -244,33 +244,72 @@ const ( ) // LookKept is how long a look below a share that found no backup repository is believed. The look walks up -// to WalkLimit folders, which on a large library is too much for every 30 s pass; a repository put under a +// to WalkLimit folders, which on a large library takes minutes on a cold disk; a repository put under a // share is found within this long. A look that found one, or was cut short, is never kept: the share stays // refused and is looked at again on the next pass. const LookKept = time.Hour -// repositoryBelow finds a backup repository within WalkDepth levels below dir. complete is false when -// the look stopped at its bound before it saw everything. -func (m *Machine) repositoryBelow(dir string) (found string, complete bool) { - now := time.Now() - if m.Now != nil { - now = m.Now() +// lookBelow is the last look below one folder, and whether another runs now. +type lookBelow struct { + found string + complete bool + at time.Time + done bool // a look has finished; until then there is no result + running bool +} + +// repositoryBelow answers the last look below dir for a backup repository, and starts the next one beside +// the caller when there is none, or it was not clean, or LookKept has passed. The pass therefore never waits +// for the walk (issue 1971: a walk of minutes held the status's time back until the module was found +// unhealthy). pending is true while no look has finished yet; the share is refused until then, and while a +// kept clean look is being renewed the old result stands. complete is false when the look stopped at its +// bound or with the process. +func (m *Machine) repositoryBelow(dir string) (found string, complete, pending bool) { + m.cleanMu.Lock() + r := m.looks[dir] + if r == nil { + if m.looks == nil { + m.looks = map[string]*lookBelow{} + } + r = &lookBelow{} + m.looks[dir] = r + } + fresh := r.done && r.found == "" && r.complete && m.clock().Sub(r.at) < LookKept + start := !fresh && !r.running + if start { + r.running = true + } + m.cleanMu.Unlock() + if start { + walk := func() { + f, c := m.walkBelow(dir) + m.cleanMu.Lock() + defer m.cleanMu.Unlock() + r.running = false + if w := m.walking(); w != nil && w.Err() != nil { + return // ended by the process stopping: no result, the old one stands + } + r.found, r.complete, r.at, r.done = f, c, m.clock(), true + } + if m.Beside != nil { + m.Beside(walk) + } else { + go walk() + } } m.cleanMu.Lock() defer m.cleanMu.Unlock() - if at, kept := m.clean[dir]; kept && now.Sub(at) < LookKept { - return "", true + if !r.done { + return "", false, true } - found, complete = m.walkBelow(dir) - if found == "" && complete { - if m.clean == nil { - m.clean = map[string]time.Time{} - } - m.clean[dir] = now - } else { - delete(m.clean, dir) + return r.found, r.complete, false +} + +func (m *Machine) clock() time.Time { + if m.Now != nil { + return m.Now() } - return found, complete + return time.Now() } // walkBelow is the look itself, breadth first, bounded by WalkDepth and WalkLimit. @@ -411,11 +450,15 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W no(fmt.Sprintf("The share %s's folder %s%s is inside the backup repository %s, and backups are never exported.", s.Name, real, via, inside)) continue } - holds, complete := m.repositoryBelow(real) + holds, complete, pending := m.repositoryBelow(real) if holds != "" { no(fmt.Sprintf("The share %s's folder %s%s holds a backup repository (%s), and backups are never exported: share the folders beside it instead.", s.Name, real, via, holds)) continue } + if pending { + no(fmt.Sprintf("The share %s's folder %s%s has not been looked through yet for a backup repository (the look runs beside the pass and takes a while on a large folder), so it is not exported yet.", s.Name, real, via)) + continue + } if !complete { no(fmt.Sprintf("The share %s's folder %s%s holds more folders than the module looks through (%d levels, at most %d folders), so it could not make sure no backup repository is under it: share smaller folders.", s.Name, real, via, WalkDepth, m.limit())) continue From 279ac41087390d81f50f4812e28cc688ed632d7d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 10:43:50 +0100 Subject: [PATCH 09/10] nfs-server: a share the export file carries stays exported while its first look runs after a restart (issue 1971, review round 1) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/nfs-server/cmd/nfs-server/machine.go | 2 +- .../nfs-server/cmd/nfs-server/review_test.go | 26 ++++++++++++++++ modules/nfs-server/cmd/nfs-server/shares.go | 31 +++++++++++++------ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/modules/nfs-server/cmd/nfs-server/machine.go b/modules/nfs-server/cmd/nfs-server/machine.go index 6025426bb..f73dfc90d 100644 --- a/modules/nfs-server/cmd/nfs-server/machine.go +++ b/modules/nfs-server/cmd/nfs-server/machine.go @@ -109,7 +109,7 @@ type Machine struct { Beside func(func()) // looks is the last look below each real path (LookKept). looks map[string]*lookBelow - cleanMu sync.Mutex + looksMu sync.Mutex // walk is the running pass's context, which ends the look below a share when the process stops. walk context.Context walkMu sync.Mutex diff --git a/modules/nfs-server/cmd/nfs-server/review_test.go b/modules/nfs-server/cmd/nfs-server/review_test.go index e987bf463..925f36a83 100644 --- a/modules/nfs-server/cmd/nfs-server/review_test.go +++ b/modules/nfs-server/cmd/nfs-server/review_test.go @@ -483,3 +483,29 @@ func TestTheLookBelowRunsBesideThePassAndNeverHoldsItsStatus(t *testing.T) { t.Errorf("the repository was not found by the next look: %+v", s.Refused) } } + +// After a restart no look is remembered; a share the previous process's export file carries stays exported, +// and nothing is reloaded, while its first look runs (a delivery never unexports a mounted share). +func TestARestartKeepsTheExportedShareWhileItsFirstLookRuns(t *testing.T) { + f := newFake(t) + f.mountinfo("/", "/srv/clean") + f.dir("/srv/clean/films/one") + f.file(ConfigFile, grantsConfig("clean=/srv/clean", "clean=ws:rw")) + f.roster("ws 10.0.0.3") + f.wants(map[string][]Want{"ws": {{Share: "clean", Access: "rw"}}}) + f.kernelReads() + first := f.machine() + s := first.Pass(context.Background(), nil) + before := f.read(ExportFile) + if refusedWhy(s, "clean") != "" || !strings.Contains(before, "/srv/clean ") { + t.Fatalf("the share was not exported to begin with: %+v %q", s.Refused, before) + } + m := f.machine() + var held []func() + m.Beside = func(w func()) { held = append(held, w) } + f.ran = nil + s = m.Pass(context.Background(), &s) + if len(held) != 1 || refusedWhy(s, "clean") != "" || f.read(ExportFile) != before || countExportfs(f.ran) != 0 { + t.Errorf("a restart with an unfinished walk unexported the share: %+v, ran %v", s.Refused, f.ran) + } +} diff --git a/modules/nfs-server/cmd/nfs-server/shares.go b/modules/nfs-server/cmd/nfs-server/shares.go index ca81ddf9e..8ef26637d 100644 --- a/modules/nfs-server/cmd/nfs-server/shares.go +++ b/modules/nfs-server/cmd/nfs-server/shares.go @@ -265,7 +265,7 @@ type lookBelow struct { // kept clean look is being renewed the old result stands. complete is false when the look stopped at its // bound or with the process. func (m *Machine) repositoryBelow(dir string) (found string, complete, pending bool) { - m.cleanMu.Lock() + m.looksMu.Lock() r := m.looks[dir] if r == nil { if m.looks == nil { @@ -279,12 +279,12 @@ func (m *Machine) repositoryBelow(dir string) (found string, complete, pending b if start { r.running = true } - m.cleanMu.Unlock() + m.looksMu.Unlock() if start { walk := func() { f, c := m.walkBelow(dir) - m.cleanMu.Lock() - defer m.cleanMu.Unlock() + m.looksMu.Lock() + defer m.looksMu.Unlock() r.running = false if w := m.walking(); w != nil && w.Err() != nil { return // ended by the process stopping: no result, the old one stands @@ -297,8 +297,8 @@ func (m *Machine) repositoryBelow(dir string) (found string, complete, pending b go walk() } } - m.cleanMu.Lock() - defer m.cleanMu.Unlock() + m.looksMu.Lock() + defer m.looksMu.Unlock() if !r.done { return "", false, true } @@ -381,7 +381,8 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W prefix, _ = netip.ParsePrefix(c.Range) } points := m.MountPoints() - backup, _ := m.Backup() // judged on every pass, never kept: see backup.go + onDisk, _ := m.Read(ExportFile) // what the previous process judged + backup, _ := m.Backup() // judged on every pass, never kept: see backup.go backup.Paths = m.realPaths(backup.Paths) shares := map[string]bool{} for _, s := range c.Shares { @@ -455,11 +456,11 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W no(fmt.Sprintf("The share %s's folder %s%s holds a backup repository (%s), and backups are never exported: share the folders beside it instead.", s.Name, real, via, holds)) continue } - if pending { + if pending && !carries(onDisk, s.Name, real) { no(fmt.Sprintf("The share %s's folder %s%s has not been looked through yet for a backup repository (the look runs beside the pass and takes a while on a large folder), so it is not exported yet.", s.Name, real, via)) continue } - if !complete { + if !pending && !complete { no(fmt.Sprintf("The share %s's folder %s%s holds more folders than the module looks through (%d levels, at most %d folders), so it could not make sure no backup repository is under it: share smaller folders.", s.Name, real, via, WalkDepth, m.limit())) continue } @@ -530,6 +531,18 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W return out, refused, said } +// carries says whether the export file already carries a share: its line, or the note that it goes to no +// node. While a share's first look is unfinished (after a restart) it stays as the previous process judged +// it, so a delivery never unexports a share clients have mounted. +func carries(file, name, real string) bool { + for _, l := range strings.Split(file, "\n") { + if strings.HasPrefix(l, real+" ") || l == "# share "+name || strings.HasPrefix(l, "# share "+name+":") { + return true + } + } + return false +} + func (m *Machine) limit() int { if m.WalkLimit > 0 { return m.WalkLimit From 87fd0cd8b98592ce851a29edb9aea15764045770 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 10:45:30 +0100 Subject: [PATCH 10/10] nfs-server: a pending share is carried only by the export line for the same folder (issue 1971, review round 2) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/nfs-server/cmd/nfs-server/review_test.go | 9 +++++++++ modules/nfs-server/cmd/nfs-server/shares.go | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/modules/nfs-server/cmd/nfs-server/review_test.go b/modules/nfs-server/cmd/nfs-server/review_test.go index 925f36a83..1f35d1385 100644 --- a/modules/nfs-server/cmd/nfs-server/review_test.go +++ b/modules/nfs-server/cmd/nfs-server/review_test.go @@ -508,4 +508,13 @@ func TestARestartKeepsTheExportedShareWhileItsFirstLookRuns(t *testing.T) { if len(held) != 1 || refusedWhy(s, "clean") != "" || f.read(ExportFile) != before || countExportfs(f.ran) != 0 { t.Errorf("a restart with an unfinished walk unexported the share: %+v, ran %v", s.Refused, f.ran) } + // The same name, a new folder holding a repository, the walk unfinished: refused, not carried by name. + f.restic("/srv/other/backup") + f.file(ConfigFile, grantsConfig("clean=/srv/other", "clean=ws:rw")) + m3 := f.machine() + m3.Beside = func(func()) {} + s = m3.Pass(context.Background(), &s) + if refusedWhy(s, "clean") == "" || strings.Contains(f.read(ExportFile), "/srv/other") { + t.Errorf("a share moved to a new folder was exported unlooked: %+v", s.Refused) + } } diff --git a/modules/nfs-server/cmd/nfs-server/shares.go b/modules/nfs-server/cmd/nfs-server/shares.go index 8ef26637d..fab71438c 100644 --- a/modules/nfs-server/cmd/nfs-server/shares.go +++ b/modules/nfs-server/cmd/nfs-server/shares.go @@ -456,7 +456,7 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W no(fmt.Sprintf("The share %s's folder %s%s holds a backup repository (%s), and backups are never exported: share the folders beside it instead.", s.Name, real, via, holds)) continue } - if pending && !carries(onDisk, s.Name, real) { + if pending && !carries(onDisk, real) { no(fmt.Sprintf("The share %s's folder %s%s has not been looked through yet for a backup repository (the look runs beside the pass and takes a while on a large folder), so it is not exported yet.", s.Name, real, via)) continue } @@ -531,12 +531,12 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W return out, refused, said } -// carries says whether the export file already carries a share: its line, or the note that it goes to no -// node. While a share's first look is unfinished (after a restart) it stays as the previous process judged +// carries says whether the export file already holds the line for this real folder (a share exported to no +// node has none and waits for its look; a share whose folder changed is a new folder). While a share's first look is unfinished (after a restart) it stays as the previous process judged // it, so a delivery never unexports a share clients have mounted. -func carries(file, name, real string) bool { +func carries(file, real string) bool { for _, l := range strings.Split(file, "\n") { - if strings.HasPrefix(l, real+" ") || l == "# share "+name || strings.HasPrefix(l, "# share "+name+":") { + if strings.HasPrefix(l, real+" ") { return true } }