diff --git a/cmd/mesh-host/ownname_test.go b/cmd/mesh-host/ownname_test.go index 1a145b58..0e77e7bf 100644 --- a/cmd/mesh-host/ownname_test.go +++ b/cmd/mesh-host/ownname_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "os" "path/filepath" "strings" "testing" @@ -54,3 +55,23 @@ func TestTheEnginesOwnNameIsReadFromItsAppliedRecordOrItsFirstComposing(t *testi t.Fatal("the writer was never made after the name was known") } } + +// §633: the engine's bound for the first seat list is the file the manifest's `view-waits-minutes` feeds, five minutes +// when it holds nothing usable. +func TestTheViewWaitsBoundIsTheManifestsSetting(t *testing.T) { + was := viewWaitsFile + defer func() { viewWaitsFile = was }() + for content, want := range map[string]time.Duration{"2\n": 2 * time.Minute, "": 5 * time.Minute, "0": 5 * time.Minute, "x": 5 * time.Minute} { + viewWaitsFile = filepath.Join(t.TempDir(), "w") + if err := os.WriteFile(viewWaitsFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if got := viewWaitsBound(); got != want { + t.Errorf("the file %q gives %v, want %v", content, got, want) + } + } + viewWaitsFile = filepath.Join(t.TempDir(), "missing") + if got := viewWaitsBound(); got != 5*time.Minute { + t.Errorf("no file gives %v", got) + } +} diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go index add27c98..9eb2aa56 100644 --- a/cmd/mesh-host/seatlist.go +++ b/cmd/mesh-host/seatlist.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "strconv" "strings" "sync" "sync/atomic" @@ -163,7 +165,7 @@ func startSeatList(ctx context.Context, session *link.Session, node string, find return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err) } st.reader = reader - st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx, + st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx, Bound: viewWaitsBound(), Clock: seatWaitClock, Say: say} told := func() { st.waiter.Changed() @@ -259,3 +261,21 @@ func (f *lateFindings) Held() []condseat.Finding { } return nil } + +// viewWaitsFile is the file the manifest declares with the content ${config:view-waits-minutes} (design 47 G §633), read at +// the engine's start as recoveryWaitFile is. +var viewWaitsFile = "/var/lib/mesh-host/view-waits-minutes" + +// viewWaitsBound is how long the reader of the seat list may wait before the engine says it: the file's minutes, the design's +// five (seatwait.Bound) when the file is missing, empty or not a positive number. +func viewWaitsBound() time.Duration { + raw, err := os.ReadFile(viewWaitsFile) + if err != nil { + return seatwait.Bound + } + n, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil || n <= 0 { + return seatwait.Bound + } + return time.Duration(n) * time.Minute +} diff --git a/internal/seatwait/seatwait.go b/internal/seatwait/seatwait.go index 667aea95..d583d9fe 100644 --- a/internal/seatwait/seatwait.go +++ b/internal/seatwait/seatwait.go @@ -71,6 +71,8 @@ type Waiter struct { Reader Reader Writer Writer Clock Clock + // Bound is how long the reader may wait; zero is the default Bound (the manifest's config `view-waits-minutes`, §633). + Bound time.Duration // Ctx is the run's context for the writer's acts; nil is the background. Ctx context.Context // Say tells the node's journal; nil says nothing. @@ -121,7 +123,7 @@ func (w *Waiter) Changed() { return // an earlier process wrote it and the list is still unread: it stands until the first full read } w.since = w.Clock.Now() - w.timer = w.Clock.AfterFunc(Bound, w.expired) + w.timer = w.Clock.AfterFunc(w.bound(), w.expired) } // Waiting says whether the bound is running or the finding stands, and since when. @@ -172,3 +174,10 @@ func observerOf(fixed string, of func() string) string { } return fixed } + +func (w *Waiter) bound() time.Duration { + if w.Bound > 0 { + return w.Bound + } + return Bound +} diff --git a/internal/seatwait/seatwait_test.go b/internal/seatwait/seatwait_test.go index 31f3a4a7..ba7b6e58 100644 --- a/internal/seatwait/seatwait_test.go +++ b/internal/seatwait/seatwait_test.go @@ -233,9 +233,27 @@ func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) { } // ADR 0636 §1, the node's finding of a seat list unread. +// §633: the bound is the manifest's `view-waits-minutes` when one is given: a finding at two minutes, none before. +func caseAGivenBoundReplacesTheFiveMinutes(t *testing.T) { + reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}} + clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)} + findings := &fakeFindings{} + w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Bound: 2 * time.Minute} + w.Changed() + clock.advance(2*time.Minute - time.Second) + if len(findings.put) != 0 { + t.Fatalf("a finding was written before the given bound: %+v", findings.put) + } + clock.advance(time.Second) + if len(findings.put) != 1 { + t.Fatalf("no finding at the given bound: %+v", findings.put) + } +} + func TestTheSeatListWait(t *testing.T) { for name, run := range map[string]func(*testing.T){ "five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead, + "a given bound replaces the five minutes": caseAGivenBoundReplacesTheFiveMinutes, "a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing, "a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain, "a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead, diff --git a/module.json b/module.json index c66f253f..d97c6be5 100644 --- a/module.json +++ b/module.json @@ -41,7 +41,9 @@ ], "invokes": [ "seat:mesh-catalog.entry", + {"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]}, "seat:mesh-catalog.seat-definitions", + "seat:mesh-grants.withheld", "seat:mesh-module-assigner.assignments", "seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", @@ -91,6 +93,12 @@ "minimum": 1, "default": 90 }, + "view-waits-minutes": { + "type": "number", + "description": "How long the node-engine waits for the first complete seat list or catalog view before it writes the finding that it waits, in minutes (design 47 G §633, ADR 0636 §1).", + "minimum": 1, + "default": 5 + }, "operator-signers": { "type": "string", "description": "The operator's public keys, ed25519 in base64, one a line: the node applies a composition made by hand only when one of these signed it (ADR 0539 clause 3). Public keys, no secret. Empty trusts no operator key.", @@ -132,6 +140,13 @@ "path": "/var/lib/mesh-host/recovery-wait-minutes", "mode": "0644", "content": "${config:recovery-wait-minutes}\n" + }, + { + "id": "view-waits", + "type": "file", + "path": "/var/lib/mesh-host/view-waits-minutes", + "mode": "0644", + "content": "${config:view-waits-minutes}\n" } ] } diff --git a/packaging/operator_keys_test.go b/packaging/operator_keys_test.go index fdf38d77..751d5436 100644 --- a/packaging/operator_keys_test.go +++ b/packaging/operator_keys_test.go @@ -3,6 +3,7 @@ package packaging_test import ( "encoding/json" "os" + "reflect" "slices" "strings" "testing" @@ -62,7 +63,7 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { } var m struct { Consumes []string `json:"consumes"` - Invokes []string `json:"invokes"` + Invokes []any `json:"invokes"` // a verb, or the long form {verb, fields} (§558) Config map[string]struct { Default any `json:"default"` } `json:"config"` @@ -71,11 +72,16 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { t.Fatal(err) } // Design 47 G §630, §633, A-77: the recovery module's three verbs it asks, and its bound as a config key of 90. - for _, v := range []string{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept"} { - if !slices.Contains(m.Invokes, v) { - t.Errorf("invokes %v lacks %s", m.Invokes, v) + // §635, §558, §617: it invokes `withheld` and names no `uses`; the catalog's revision in the long form with the field none-yet. + for _, v := range []any{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept", + "seat:mesh-grants.withheld", map[string]any{"verb": "seat:mesh-catalog.revision", "fields": []any{"none-yet"}}} { + if !slices.ContainsFunc(m.Invokes, func(have any) bool { return reflect.DeepEqual(have, v) }) { + t.Errorf("invokes %v lacks %v", m.Invokes, v) } } + if !strings.Contains(string(raw), `"content": "${config:view-waits-minutes}\n"`) || m.Config["view-waits-minutes"].Default != float64(5) { + t.Errorf("module.json declares no file with the content ${config:view-waits-minutes} or its default is not 5 (§633)") + } if !strings.Contains(string(raw), `"content": "${config:recovery-wait-minutes}\n"`) { t.Errorf("module.json declares no file with the content ${config:recovery-wait-minutes} (design 47 G A-112)") }