From a484d5f9605067a64f3155420d544a05612de028 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:48:14 +0100 Subject: [PATCH] S5: the whole node reads its seat list after a holder that re-served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live case of ace, in process against a real bus: the stand-in of the seat mesh-catalog answers as nothing serves it (no responders) while the node's reader first asks, then serves; the same reader reads the list within the bus's retry bound, with no event of the seat and no hand act (issue 1966, ADR 0636 §8). The test of an unread list now reads the reason that names the bootstrapped seats. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- cmd/mesh-host/nodeagent_integration_test.go | 36 +++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 7a3551e..f8da072 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -7,6 +7,7 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "fmt" "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" @@ -20,6 +21,7 @@ import ( engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -72,7 +74,10 @@ type itBed struct { seatSilent atomic.Bool // seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked. seatAsked atomic.Int32 - findings nats.KeyValue + // seatAway makes the stand-in answer as the bus answers a subject nothing serves (no responders), as while the seat's + // holder re-serves (issue 1966). + seatAway atomic.Bool + findings nats.KeyValue } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what @@ -918,6 +923,10 @@ func (b *itBed) standInSeat() { } _ = json.Unmarshal(m.Data, &q) b.seatAsked.Add(1) + if b.seatAway.Load() { + _ = m.RespondMsg(&nats.Msg{Header: nats.Header{"Status": []string{"503"}}}) // the bus's no responders + return + } var result any switch { case b.seatSilent.Load(): @@ -1002,7 +1011,14 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { b.mu.Lock() defer b.mu.Unlock() said := strings.Join(b.said, "\n") - return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList) + // web's reason names the bootstrapped seats the node holds (issue 1966). + var boot []string + for _, d := range seatlist.Bootstrapped() { + boot = append(boot, d.Seat) + } + sort.Strings(boot) + return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && + strings.Contains(said, "web: "+fmt.Sprintf(selfcompose.WaitsForTheSeatListHeld, len(boot), strings.Join(boot, ", "))) }) if b.file("web") != "" { t.Fatalf("the node applied web before it read the seat list: %q", b.file("web")) @@ -1047,6 +1063,10 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { // The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and // the node waits on the reader's Ready before it says the read is over (ADR 0636 §1). +// +// Issue 1966, the live case of ace: the seat's holder re-serves while the node starts, so the node's reader is answered as +// nothing serves the seat (no responders); once the holder serves again the reader reads the list within the bus's retry +// bound, with no event of the seat and no hand act. func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { clock := &fiveMinutes{} was := seatWaitClock @@ -1054,8 +1074,17 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { t.Cleanup(func() { seatWaitClock = was }) b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + b.seatAway.Store(true) stop, _ := h8(t, b, false) defer stop() + var st *seatListState + within(t, 5*time.Second, "the node started no reader of the seat list", func() bool { st = seatListNow.Load(); return st != nil }) + asked := b.seatAsked.Load() + within(t, 10*time.Second, "the node's reader did not ask the seat while nothing served it", func() bool { + now := st.reader.Status() + return b.seatAsked.Load() > asked || (!now.Reading && !now.Heard) + }) + b.seatAway.Store(false) within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool { st := seatListNow.Load() if st == nil { @@ -1065,4 +1094,7 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { waiting, _ := st.waiter.Waiting() return st.reader.Status().Heard && !waiting }) + if seatListNow.Load() != st { + t.Error("the list was read by the reader of a run begun since, not by the one that met no responders") + } }